Skip to content

feat(deepseek_v4): faithful THD context-parallel path for DS4 CSA (bounded activation memory) - #7

Merged
flameagainst merged 3 commits into
verl-project:litefrom
ISEEKYAN:feature/ds4-csa-thd-cp-verl
Jul 13, 2026
Merged

feat(deepseek_v4): faithful THD context-parallel path for DS4 CSA (bounded activation memory)#7
flameagainst merged 3 commits into
verl-project:litefrom
ISEEKYAN:feature/ds4-csa-thd-cp-verl

Conversation

@ISEEKYAN

Copy link
Copy Markdown

What

Implements the THD-packed context-parallel path for DeepSeek-V4 Compressed Sparse Attention (CSA) in Megatron-Lite, as a faithful port of Megatron Core's CompressedSparseAttention._forward_thd_cp (NVIDIA/dev, NVIDIA#5087). This is the bounded-memory CP path for DS4 CSA — an alternative to the streaming-softmax approach in #6, built by importing Core's DSv4 THD-CP helpers zero-copy rather than re-deriving the sparse math.

Design

  • mcore = NVIDIA/dev (container-provided). This PR does not vendor or modify megatron/core; the lite CSA imports the DSv4 THD-CP helpers directly: csa_cp_utils (prepare_cp_compressor_input, exchange_cp_boundary_hidden, compute_cp_indexer_topk, _thd_cp_position_ids), csa_cp_layout_kernels.build_attention_indices, and the THD sparse-attn kernels (dsa_sparse_attn(is_thd=True), FusedIndexerSparseAttnFromTopkFunc, unfused_compressed_sparse_attn).
  • THD is the primary CP route. protocol.py no longer pops packed_seq_params and model.py no longer discards the THD signal — cu_seqlens is threaded end-to-end (DeepseekV4ModelDeepseekV4LayerDeepseekV4CSAAttentionCompressedSparseAttention).
  • The BSHD dense-softmax fallback is deleted (it was the O(S²/cp) activation-memory source); the only BSHD routes are the existing CP=1 fused sparse kernels.
  • Kernel / indexer-loss knobs are implementation config (constructor args, mirroring GLM-5's DSA), not part of the HF DeepseekV4Config; fused DSA is the production default, unfused is a debug fallback.

GPU verification (cw H100/SM90, full evidence)

Validated with a standalone torchrun CP1-vs-CP4 parity harness on the lite CompressedSparseAttention (real DS4 geometry head_dim=512; variable-length multi-sample THD packs with sequence boundaries straddling CP shard cuts and non-ratio-aligned lengths):

  • Forward parity: CP4 == CP1 to the fused kernel's own run-to-run non-determinism floor (~1e-3); bit-identical on the unfused/debug path.
  • Backward parity: CP4 == CP1 within bf16 reduction-order rounding (input-grad ~1 bf16 ULP; fp32 spot-check confirms pure FP noise, not an algorithmic asymmetry); loss identical; no densify OOM at 16384 on the fused path.
  • Memory (the point): CP=4 bounds per-rank activation memory — CP1/CP4 ratio 2.0× @4k → 3.4× @32k (approaching 4× as sequence length grows); the fused path is ~3× leaner in absolute terms than the unfused densifying path (CP1@32768: 12.1 GiB vs 35.9 GiB).
  • q_padding_mask / indexer-loss path: runs green at CP1 and CP4.

Environment note

The fused DSA CP indexer requires cudnn-frontend ≥ 1.27 (cudnn.DSA.indexer_forward_wrapper must accept q_causal_offsets); 1.25.0 blocks the fused CP path. FlashMLA sparse-prefill requires the real DS4 head geometry (head_dim=512 / d_v=512).

🤖 Generated with Claude Code

ISEEKYAN and others added 3 commits July 13, 2026 08:02
Implement the THD-packed context-parallel branch for DSv4 Compressed Sparse
Attention in the lite CSA module, faithfully porting Megatron Core's
CompressedSparseAttention._forward_thd_cp and importing Core's CP helpers
(csa_cp_utils, csa_cp_layout_kernels), THD sparse-attention kernels, and the
boundary exchange zero-copy.

- csa.py: add _forward_thd_packed / _forward_thd_cp / _project_boundary_kv and
  a compressor _forward_thd; thread packed_seq_params as the primary CP route;
  delete the BSHD dense-softmax fallback and its now-dead helpers.
- deepseek_v4 model.py / protocol.py: stop discarding packed_seq_params
  (protocol pop / model del) and thread cu_seqlens end-to-end to CSA.
- add a CPU unit test for the THD-CP layout/dispatch and the CP=1==unsharded
  invariant (numeric assertion is GPU-gated: the Core layout kernels require
  CuTeDSL/CUDA).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Yan Bai <bayan@nvidia.com>
_forward_thd_packed built the TE-THD key as (total, 1, 1, 1, hn) via a spurious
.unsqueeze(-2); Core's _forward_thd_cp expects a 4D (total, 1, 1, hn) key and
does key.squeeze(-2).squeeze(1) to recover a 2D (total, hn) kv_local. The extra
dim left kv_local 3D and broke torch.cat with the 2D boundary/compressed KV.
kv.permute(2, 0, 1, 3) already yields the correct (total, 1, 1, hn); drop the
unsqueeze.

Verified on 4x H100 (cw, job 13902888): CP=4 sharded == CP=1 unsharded output
bit-identical (max_abs=max_rel=0), backward grads finite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Yan Bai <bayan@nvidia.com>
The fused-kernel and indexer-loss knobs are implementation config, not part
of the HF model config, and must not be read from DeepseekV4Config via
defensive getattr. Move them to explicit CompressedSparseAttention constructor
arguments (mirroring GLM-5's DSA), keeping DeepseekV4Config strictly HF-aligned:

- apply_dsa_kernel_fusion defaults to True (fused DSA kernels are the production
  path; the unfused sparse-attn / indexer-loss path is a debug fallback).
- dsa_indexer_loss_coeff / dsa_indexer_use_sparse_loss / calculate_per_token_loss
  become constructor args with Core-matching defaults (0.0 / False / False).
- num_nextn_predict_layers is a real HF config field; read it directly.

No getattr fallbacks remain in csa.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Yan Bai <bayan@nvidia.com>

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements the DeepSeek-V4 Compressed Sparse Attention (CSA) THD context-parallel (CP) path in Megatron-Lite, enabling sequence-parallel training with packed sequences and removing the old BSHD dense fallback. It also adds corresponding unit tests to verify the layout, differentiability, and CP=1 correctness. The review feedback highlights several robustness improvements to prevent potential AttributeErrors, specifically by guarding against empty compressed_group_ids and using getattr to safely access attributes on packed_seq_params.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

)
)

if indexer is not None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

To prevent potential AttributeError when compressed_group_ids is empty (e.g., on short sequences or specific CP configurations where some ranks have 0 compressed blocks), we should guard the indexer logic by checking if compressed_group_ids.shape[0] > 0.

Suggested change
if indexer is not None:
if indexer is not None and compressed_group_ids.shape[0] > 0:

Comment on lines +944 to +952
compressed_kv_local, _ = self.compressor._forward_thd(
hidden_compact,
cu_seqlens,
max_seqlen_q=max_seqlen_q,
compressed_group_ids=compressed_group_ids,
)
compressed_kv_rank_major = gather_from_sequence_parallel_region(
compressed_kv_local.squeeze(1), group=cp_group
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Similarly, if compressed_group_ids is empty, self.compressor._forward_thd will return None, None, causing an AttributeError when calling .squeeze(1). Guard this block to ensure it only runs when there are compressed groups.

Suggested change
compressed_kv_local, _ = self.compressor._forward_thd(
hidden_compact,
cu_seqlens,
max_seqlen_q=max_seqlen_q,
compressed_group_ids=compressed_group_ids,
)
compressed_kv_rank_major = gather_from_sequence_parallel_region(
compressed_kv_local.squeeze(1), group=cp_group
)
if compressed_group_ids.shape[0] > 0:
compressed_kv_local, _ = self.compressor._forward_thd(
hidden_compact,
cu_seqlens,
max_seqlen_q=max_seqlen_q,
compressed_group_ids=compressed_group_ids,
)
compressed_kv_rank_major = gather_from_sequence_parallel_region(
compressed_kv_local.squeeze(1), group=cp_group
)

Comment on lines +688 to +694
def _thd_cu_seqlens(self, packed_seq_params: Any) -> torch.Tensor:
"""Padded cu_seqlens (falling back to unpadded) for the THD packed layout."""
return (
packed_seq_params.cu_seqlens_q_padded
if packed_seq_params.cu_seqlens_q_padded is not None
else packed_seq_params.cu_seqlens_q
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using getattr to access attributes on packed_seq_params is safer and more robust than direct attribute access, especially when integrating with external frameworks or custom runtimes where packed_seq_params might be a different class or dictionary.

    def _thd_cu_seqlens(self, packed_seq_params: Any) -> torch.Tensor:
        """Padded cu_seqlens (falling back to unpadded) for the THD packed layout."""
        padded = getattr(packed_seq_params, "cu_seqlens_q_padded", None)
        return padded if padded is not None else getattr(packed_seq_params, "cu_seqlens_q", None)

if l_local != key.shape[0]:
raise RuntimeError("DSv4 THD CP path currently supports self-attention only.")
cu_seqlens = self._thd_cu_seqlens(packed_seq_params)
max_seqlen_q = int(packed_seq_params.max_seqlen_q)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Use getattr to safely retrieve max_seqlen_q from packed_seq_params to avoid potential AttributeError.

Suggested change
max_seqlen_q = int(packed_seq_params.max_seqlen_q)
max_seqlen_q = int(getattr(packed_seq_params, "max_seqlen_q", 0))

Comment on lines +987 to +994
cu_seqlens_q_unpadded = None
if (
packed_seq_params.cu_seqlens_q is not None
and packed_seq_params.cu_seqlens_q_padded is not None
and packed_seq_params.cu_seqlens_q.data_ptr()
!= packed_seq_params.cu_seqlens_q_padded.data_ptr()
):
cu_seqlens_q_unpadded = packed_seq_params.cu_seqlens_q

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Safely retrieve unpadded and padded cu_seqlens using getattr to prevent potential AttributeError if packed_seq_params is missing these attributes.

Suggested change
cu_seqlens_q_unpadded = None
if (
packed_seq_params.cu_seqlens_q is not None
and packed_seq_params.cu_seqlens_q_padded is not None
and packed_seq_params.cu_seqlens_q.data_ptr()
!= packed_seq_params.cu_seqlens_q_padded.data_ptr()
):
cu_seqlens_q_unpadded = packed_seq_params.cu_seqlens_q
cu_seqlens_q = getattr(packed_seq_params, "cu_seqlens_q", None)
cu_seqlens_q_padded = getattr(packed_seq_params, "cu_seqlens_q_padded", None)
cu_seqlens_q_unpadded = None
if (
cu_seqlens_q is not None
and cu_seqlens_q_padded is not None
and cu_seqlens_q.data_ptr() != cu_seqlens_q_padded.data_ptr()
):
cu_seqlens_q_unpadded = cu_seqlens_q

@ISEEKYAN
ISEEKYAN marked this pull request as ready for review July 13, 2026 15:07
@flameagainst
flameagainst merged commit 8f180bc into verl-project:lite Jul 13, 2026
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.

2 participants