Skip to content

Add unfused SBHD compressed sparse attention - #6400

Open
FDecaYed wants to merge 2 commits into
NVIDIA:mainfrom
FDecaYed:codex/dsv4-v2-d1-20260810
Open

Add unfused SBHD compressed sparse attention#6400
FDecaYed wants to merge 2 commits into
NVIDIA:mainfrom
FDecaYed:codex/dsv4-v2-d1-20260810

Conversation

@FDecaYed

@FDecaYed FDecaYed commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Add unfused SBHD compressed sparse attention

Summary

Add the reference SBHD Compressed Sparse Attention implementation needed by DeepSeek-V4, while keeping ordinary DSA on the current-main implementation and API.

Related to #5795.

Scope

Supported scope

BF16, eager, SBHD, fixed-length, TP1/PP1/CP1.

Non-goals

Review boundary and dependencies

#5929 and #5944 are merged in main. This branch contains one residual commit: 10ae87b793306371657c41ffc2ee4880c842907a.

Please review the complete PR diff: 6 files, +2,460/-9.

Current-main ordinary DSA remains the source of truth. #6020 defines the main-first DSA versus dev-only CSA ownership boundary, while #5960 supplies the corrected full-denominator teacher semantics incorporated here. #5795 remains historical feature and test provenance, not the patch base.

Validation

Passed locally on the final changeset:

  • clean worktree;
  • valid GPG signature and required Signed-off-by trailer;
  • git diff --check;
  • Python parsing and Ruff 0.9.10 on changed Python files;
  • isort 5.13.2 on newly import-touched files;
  • Black 26.3 on attributable new hunks.

The macOS host has no usable Torch/CUDA runtime, so no local GPU/runtime result is claimed. Full functional CI passed on exact head 10ae87b793306371657c41ffc2ee4880c842907a: run 32114744957 (L1, 5 repeats, non-lightweight; 151 jobs succeeded, 8 expected skips, 0 failures, and the final Nemo_CICD_Test gate passed). No dependency or lock-file change is part of this commit.

@FDecaYed FDecaYed added the deepseekv4 DeepSeek V4 PRs label Aug 10, 2026
@copy-pr-bot

copy-pr-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

Signed-off-by: Deyu Fu <deyuf@nvidia.com>
@FDecaYed

Copy link
Copy Markdown
Contributor Author

/ok to test 10ae87b

@Phlip79

Phlip79 commented Aug 18, 2026

Copy link
Copy Markdown
Member

/claude strict-review

Comment on lines +203 to +210
kv_t = kv_full.permute(1, 0, 2)

safe_indices = topk_indices.clamp(min=0).long() # [b, sq, topk]
safe_indices_exp = safe_indices.unsqueeze(-1).expand(-1, -1, -1, hn) # [b, sq, topk, hn]
# [b, n_kv, hn] -> [b, 1, n_kv, hn] -> gather -> [b, sq, topk, hn]
kv_gathered = torch.gather(
kv_t.unsqueeze(1).expand(-1, sq, -1, -1), dim=2, index=safe_indices_exp
)

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.

[CRITICAL Performance] torch.gather over an expanded input materializes a full [b, sq, n_kv, hn] gradient buffer in backward.

The forward is fine — expand is stride-0 and gather reads it strided. The backward is not: gather_backward is grad.new_zeros(self.sizes()).scatter_add_(dim, index, grad), and self here is the logical [b, sq, n_kv, hn] expanded view. So every backward through this kernel allocates a dense tensor of b · sq · n_kv · hn elements.

For a realistic CSA-4 layer at sq=4096, window=128, ratio=4n_compressed=1024, n_kv=5120, v_head_dim=128, b=1: 4096 · 5120 · 128 · 2 B ≈ 5.4 GB of transient allocation per layer per backward (×4 if the graph ever holds it in fp32). This will OOM long before the fp32 scores tensor does, and it scales as sq · n_kv rather than sq · topk.

The fix is already used elsewhere in this file — _compute_unfused_csa_non_compressed_lse (lines 292-308) flattens to batch-major and uses index_select, whose backward is an index_add into [b * n_kv, hn]. Applying the same shape here:

    n_kv = kv_full.size(0)
    # [n_kv, b, hn] -> [b, n_kv, hn] -> [b * n_kv, hn]
    kv_flat = kv_full.permute(1, 0, 2).reshape(b * n_kv, hn)
    batch_offsets = (
        torch.arange(b, device=kv_full.device, dtype=torch.int64) * n_kv
    ).view(b, 1, 1)
    safe_indices = topk_indices.clamp(min=0).long() + batch_offsets  # [b, sq, topk]
    # backward is an index_add into [b * n_kv, hn], not a [b, sq, n_kv, hn] zeros tensor
    kv_gathered = kv_flat.index_select(0, safe_indices.reshape(-1)).view(b, sq, topk, hn)

Everything downstream (kv_g, the two einsums, the invalid mask) is unchanged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — fixed in d02991d. The sparse-attention gather now flattens batch and KV position to [b * n_kv, hn], applies per-batch offsets, and uses index_select, so backward accumulates into the compact KV buffer instead of materializing [b, sq, n_kv, hn]. I added a CUDA regression test covering b=2, repeated indices, and -1 padding, with forward and query/KV/sink gradients checked against a loop oracle; it also explicitly rejects torch.gather. The exact-head full functional CI passed.

Comment on lines +481 to +484
weights = torch.softmax(score, dim=1, dtype=torch.float32).to(kv.dtype)
kv = (kv * weights).sum(dim=1) # [n_compressed, b, head_dim]

kv = self.norm(kv.to(x.dtype))

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.

[IMPORTANT Implementation] Pooling weights are computed in FP32 and then immediately thrown back to BF16 before the weighted sum.

torch.softmax(..., dtype=torch.float32) buys FP32 normalization, but .to(kv.dtype) discards it before the product and the reduction. For compress_ratio == 128 (non-overlapping, coff = 1) each weight is ~1/128 ≈ 0.0078; BF16's 8-bit mantissa gives ~0.4% relative error per weight, and the kv * weights product is also formed in BF16. sum(dim=1) does accumulate in FP32 internally on CUDA, but that does not recover precision already lost in the operands. This is the value path feeding the compressed KV that the whole sparse-attention denominator depends on, so the error propagates into both the student top-k and the teacher target.

Keep the reduction in FP32 and cast once:

        weights = torch.softmax(score, dim=1, dtype=torch.float32)
        kv = (kv.float() * weights).sum(dim=1).to(x.dtype)  # [n_compressed, b, head_dim]

        kv = self.norm(kv)

This also removes the no-op kv.to(x.dtype) on line 484 (kv is already x.dtype there today).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — fixed in d02991d. Compressor pooling now keeps the softmax weights, kv.float() * weights, and the reduction in FP32, then casts the pooled result once to x.dtype before normalization. The new BF16-input test checks the forward result and both KV/score gradients exactly against an FP32 pooling oracle. The exact-head full functional CI passed.

Comment on lines +356 to +365
csa_window_size: int = 128
"""Sliding window size for compressed sparse attention."""

csa_compress_ratios: Optional[List[int]] = None
"""Per-layer compress ratios, e.g. [0, 0, 4, 128, 4, 128, ...]."""

csa_compress_rotary_base: float = 40000.0
"""RoPE base for compressed KV positions in compressed sparse attention."""

csa_dense_mode: bool = False

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.

[IMPORTANT Compatibility] Two of these four new public config fields have no runtime reader in this PR.

Grepping the whole tree after this change:

  • csa_window_size — read at csa.py:716. ✅
  • csa_dense_mode — read at csa.py:748. ✅
  • csa_compress_ratios — read only in test_attention_variant_csa.py. Production code takes compress_ratio as an explicit CompressedSparseAttention.__init__ argument and never indexes this list. The CompressedSparseAttention docstring claims it does (see separate comment).
  • csa_compress_rotary_basezero readers anywhere, production or test. The rotary module is injected via the rotary_pos_emb argument, which this PR never constructs.

TransformerConfig is public API: once a field ships, its name, type, and default are effectively frozen, and 40000.0 in particular is a numeric default that a future consumer either honors or silently contradicts. Shipping it dead means the first PR that actually wires the rotary base inherits a name and default nobody validated against the DSv4 reference.

Please either move csa_compress_ratios / csa_compress_rotary_base into the PR that consumes them (#6405 per the scope section), or add an explicit TODO naming that PR so the dead fields are traceable:

    # TODO(#6405): consumed by HybridModel layer construction, which selects the
    # per-layer compress ratio and builds the compressed-KV rotary embedding.
    # No runtime reader in this PR.
    csa_compress_ratios: Optional[List[int]] = None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d02991d. I added an explicit TODO above these two fields. After the follow-on slices were recut, #6402—not #6405—is the actual consumer: it selects the per-layer compression ratio and constructs the compressed-KV rotary embedding. The comment also records that neither field has a production reader in this primitive-only PR.

Comment on lines +673 to +682
"""Sparse core attention for CompressedSparseAttention.

Combines sliding window attention with compressed KV attention. The spec always
provides compressor and indexer submodule specs; this ``__init__`` inspects
``config.csa_compress_ratios[layer_idx]`` and conditionally builds them:

* ``ratio == 0``: window-only (compressor and indexer NOT built)
* ``ratio == 4``: window + 4x compressed + learned Indexer (both built)
* ``ratio == 128``: window + 128x compressed, attend to all (compressor built only)
"""

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 Other] This docstring describes behavior the class does not have — a wrong docstring here is worse than none, because it points the reader at the wrong wiring contract.

Three inaccuracies:

  1. "this __init__ inspects config.csa_compress_ratios[layer_idx]" — it does not. compress_ratio arrives as an explicit __init__ argument (line 698) and csa_compress_ratios is never read in production code.
  2. "ratio == 128: window + 128x compressed, attend to all (compressor built only)" — the compressor is built for any ratio > 1 (line 731), not specifically 128, and the indexer is built for exactly ratio == 4 (line 747). The three enumerated cases read as an exhaustive dispatch table when the actual predicates are ratio > 1 and ratio == 4.
  3. "ratio == 0: window-only" — ratio == 1 also lands in window-only, and the default is compress_ratio: int = 0. The ratio == 4 case additionally depends on config.csa_dense_mode being False, which isn't mentioned.

Suggested rewrite:

    """Sparse core attention for CompressedSparseAttention.

    Combines sliding-window attention with compressed KV attention.  The spec always
    provides compressor and indexer submodule specs; which are actually built depends on
    the ``compress_ratio`` passed by the caller:

    * ``ratio <= 1``: window-only (neither compressor nor indexer is built).
    * ``ratio > 1``:  window + compressed KV via ``Compressor``.
    * ``ratio == 4`` and not ``config.csa_dense_mode``: additionally builds ``CSAIndexer``
      for learned top-k retrieval over compressed positions.  Otherwise all causally
      valid compressed positions are attended (dense over the compressed axis).
    """

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d02991d. I rewrote the docstring to match the actual constructor contract: compress_ratio is caller-provided; ratios <= 1 are window-only; ratios > 1 build the compressor; and ratio 4 builds the indexer only when dense mode is disabled. It no longer claims to read config.csa_compress_ratios or presents 0/4/128 as an exhaustive dispatch table.

Comment on lines +719 to +728
self.n_local_heads = config.num_attention_heads

if softmax_scale is None:
softmax_scale = config.v_head_dim**-0.5
self.softmax_scale = softmax_scale

# Learnable attention sink per head, kept in reference-checkpoint FP32.
self.attn_sink = mark_keep_in_fp32(
nn.Parameter(torch.zeros(self.n_local_heads, dtype=torch.float32))
)

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.

[IMPORTANT Correctness] n_local_heads holds the global head count, and attn_sink is sized from it — so TP > 1 fails at an unrelated line with an opaque message.

config.num_attention_heads is the model-wide head count, not the TP-local one. Meanwhile unfused_compressed_sparse_attn does attn_sink.view(1, np_, 1, 1) (line 225), where np_ comes from query.size(2) — the TP-local head count. With tp_size == 2 this raises RuntimeError: shape '[1, 8, 1, 1]' is invalid for input of size 16 from inside the attention kernel, with nothing pointing at the head-count mismatch.

Two separate problems:

  1. Naming. n_local_heads asserts the opposite of what the value is. Per the review guidance on parallel/distributed naming, this should be either num_attention_heads (what it is) or actually made local.
  2. No guard. The PR scope is TP1, but nothing enforces it. A silent-until-cryptic failure at TP>1 is the worst outcome; either shard the parameter or reject the config up front.

Preferred (parameter is genuinely per-local-head, matching query):

        tp_size = self.pg_collection.tp.size()
        assert config.num_attention_heads % tp_size == 0, (
            f"num_attention_heads ({config.num_attention_heads}) must be divisible by "
            f"tensor-parallel size ({tp_size})"
        )
        self.num_local_attention_heads = config.num_attention_heads // tp_size

        # Learnable attention sink per local head, kept in reference-checkpoint FP32.
        self.attn_sink = mark_keep_in_fp32(
            nn.Parameter(torch.zeros(self.num_local_attention_heads, dtype=torch.float32))
        )

Note that sharding attn_sink also needs tensor_model_parallel/partition_dim marking so it is checkpointed and all-reduced correctly. If that's out of scope for the unfused slice, the minimal alternative is an explicit rejection here:

        if self.pg_collection.tp.size() > 1:
            raise ValueError(
                "CompressedSparseAttention does not support tensor parallelism yet "
                f"(tp_size={self.pg_collection.tp.size()}); attn_sink is not sharded."
            )

Either way, a shape assertion at the query boundary in forward (assert np == self.attn_sink.numel()) would turn the remaining failure mode into a readable one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d02991d. Since this native SBHD slice explicitly supports TP1 only, I chose the scoped rejection path rather than introducing partially sharded sink semantics. The constructor now rejects tp_size != 1 with a clear error, n_local_heads was renamed to num_attention_heads, and both the module boundary and direct attention helper validate the query/sink head count. Focused tests cover TP rejection, module-level head mismatch, and direct sink mismatch.

Comment on lines +559 to +567
def _normalize_indexer_teacher_target(
target: torch.Tensor, non_compressed_lse: torch.Tensor | None
) -> torch.Tensor:
"""L1-normalize teacher mass without changing the legacy DSA path."""
if non_compressed_lse is None:
return dsa_indexer_loss.normalize_indexer_target(target)
return target / target.sum(dim=-1, keepdim=True).clamp_min(torch.finfo(torch.float32).tiny)


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.

[IMPORTANT Implementation] The new branch silently changes the normalization floor from 1e-10 to 1.2e-38, and the zero-mass row it admits still produces a gradient.

normalize_indexer_target clamps with INDEXER_LOSS_EPS = 1e-10; this branch clamps with torch.finfo(torch.float32).tiny ≈ 1.18e-38. The looser floor is arguably more correct for CSA (with the full denominator, legitimate row masses can be far below 1e-10 without being degenerate, and 1e-10 would wrongly shrink them) — but the divergence is undocumented and the failure mode it opens is not handled.

In _compute_indexer_teacher_probabilities, each head's compressed mass is m_h = C_h / (W_h + C_h) where W_h = exp(non_compressed_lse). When the window+sink logits dominate — the expected regime early in training, before the compressor has learned anything — exp(masked_scores - full_lse) underflows: any compressed logit more than ~88 below full_lse becomes exactly 0.0 in FP32. If that holds for every head and every compressed key in a row, target.sum(dim=-1) is exactly 0, so 0 / tiny = 0 and the normalized target is all-zero.

That row is valid (it is not masked, query_valid_rows does not cover it), so bwd_fused_indexer_loss_naive line 872 computes grad = index_scores_softmax - 0 = predict, feeding a nonzero gradient toward a degenerate all-zero target. The KL term itself is 0, so the loss value gives no warning — this only shows up as unexplained indexer drift.

The underflow is avoidable entirely by staying in log space. The final target is mathematically sum_h m_h · softmax_h(compressed) / sum_h m_h, and m_h = sigmoid(compressed_lse_h - non_compressed_lse_h), which is stable for any finite input:

    masked_scores = attention_scores.float().masked_fill(~expanded_valid_mask, float("-inf"))
    compressed_lse = torch.logsumexp(masked_scores, dim=-1)
    # m_h = C_h / (W_h + C_h) == sigmoid(log C_h - log W_h); no exp() underflow.
    head_mass = torch.sigmoid(compressed_lse - non_compressed_lse.float())
    probabilities = torch.softmax(masked_scores, dim=-1) * head_mass.unsqueeze(-1)
    return torch.where(expanded_valid_mask, probabilities, torch.zeros_like(probabilities))

At minimum, please add a comment here explaining why this path deliberately does not use INDEXER_LOSS_EPS, so the next reader doesn't "fix" it back.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — fixed in d02991d. The external-mass path now separates the conditional distribution over compressed keys from each head’s compressed mass, keeps the latter in log space, and applies one common per-row shift across heads when the absolute mass would underflow; that shift cancels in the final L1 normalization. This path deliberately does not use INDEXER_LOSS_EPS: any positive compressed mass is normalized, while an exactly zero row stays zero. The manual KL gradient is now predict * target.sum(-1) - target, so zero-mass rows produce zero gradient. Added regressions for a 2-head/2-key LSE≈1000 float64 oracle, fully masked finite-zero target/autograd, valid zero-mass manual gradients, and unchanged moderate/legacy behavior. The exact-head full functional CI passed.

Comment on lines +853 to +855
non_compressed_lse = _compute_unfused_csa_non_compressed_lse(
query, kv, self.attn_sink, window_idxs, self.softmax_scale
)

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.

[IMPORTANT Performance] The sliding-window logits are computed twice per training step per CSA-4 layer.

_compute_unfused_csa_non_compressed_lse gathers the window KV and computes q · k over exactly the same (query, kv, window_idxs) triple that unfused_compressed_sparse_attn will recompute 50 lines later (line 904 → scores at line 218, whose first window_size columns are these same logits). The teacher only needs the row-wise LSE, which the main kernel already has all the inputs for.

At sq=4096, window=128, np=128, head_dim=128 that is an extra sq · window · np · head_dim ≈ 8.6 GFLOP per layer per step, in FP32, plus a second full gather of the window KV — on top of the redundant index_select. On a 60-layer DSv4 config with half the layers at ratio 4, that is a measurable fraction of attention time for a value that is a byproduct of work already being done.

Two ways out, either is fine:

  • Have unfused_compressed_sparse_attn optionally return the per-row LSE restricted to the first window_size index columns (plus sink), and reorder so the sparse kernel runs before the indexer loss. This needs the topk concat to be split, so it's the more invasive option.
  • Cheaper: hoist the window-KV gather so both the teacher helper and the main kernel consume one index_select result, and drop chunk_size looping in favor of the already-materialized gather.

If neither fits the unfused reference slice, please add a comment stating the duplication is accepted and that #6404 (fused kernels) is expected to eliminate it — right now nothing signals that this is a known cost.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — this optimization is intentionally deferred in the native reference path. I added an explicit TODO documenting the duplicate window-logit/gather work, that #6404’s fused training backend avoids it, and that the native fallback should later share the gathered window KV/logits. This PR keeps the correctness-first implementation unchanged.

Comment on lines +836 to +889
causal_mask = (
torch.arange(n_compressed, device=x.device).unsqueeze(0).expand(sq, -1)
)
positions = torch.arange(1, sq + 1, device=x.device).unsqueeze(1)
causal_mask = (
torch.where(causal_mask >= positions // self.compress_ratio, float("-inf"), 0.0)
.unsqueeze(0)
.expand(b, -1, -1)
) # [b, sq, n_compressed]

if self.training and torch.is_grad_enabled():
q_indexer, k_indexer, weights_indexer = self.indexer.forward_before_topk(
x_det, qr_det
)
indexer_loss_coeff = self.config.dsa_indexer_loss_coeff or 0.0
key_for_loss = compressed_kv.unsqueeze(2).expand(-1, -1, np, -1)
weights_for_unfused = weights_indexer.float() * self.indexer.softmax_scale
non_compressed_lse = _compute_unfused_csa_non_compressed_lse(
query, kv, self.attn_sink, window_idxs, self.softmax_scale
)
topk_indices_compressed, indexer_loss = FusedDSAIndexerLoss.apply(
q_indexer,
weights_for_unfused,
k_indexer,
query.detach(),
key_for_loss.detach(),
self.softmax_scale,
min(self.indexer.index_topk, n_compressed),
indexer_loss_coeff,
causal_mask,
self.config.dsa_indexer_use_sparse_loss,
self.indexer.pg_collection,
None,
None,
None,
None,
self.config.calculate_per_token_loss,
True,
non_compressed_lse,
)
if indexer_loss_coeff > 0:
DSAIndexerLossLoggingHelper.save_loss_to_tracker(
loss=indexer_loss,
layer_number=self.layer_number,
num_layers=self.config.num_layers + (self.config.mtp_num_layers or 0),
)
else:
_, topk_indices_compressed = self.indexer(x_det, qr_det, mask=causal_mask)

n_valid_per_pos = positions // self.compress_ratio # [sq, 1]
valid = (topk_indices_compressed >= 0) & (topk_indices_compressed < n_valid_per_pos)
compress_topk_idxs = torch.where(
valid, topk_indices_compressed + offset, torch.tensor(-1, device=x.device)
)

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 Simplification] Per-forward reconstruction of constants that only depend on (sq, n_compressed, ratio, b).

Three allocations here are invariant across every step of training for a fixed shape, yet are rebuilt on every layer's every forward:

  • causal_mask (lines 836-844): a [b, sq, n_compressed] FP32 tensor. At sq=4096, n_compressed=1024 that's a 16 MB allocation (stride-0 in batch, so 16 MB not b × 16 MB) plus four kernel launches, per layer per step.
  • positions / n_valid_per_pos (lines 839, 885): tiny, but two more launches.
  • torch.tensor(-1, device=x.device) (line 888): constructs a 0-dim tensor from a Python int, which is a host→device copy on the critical path. torch.where accepts a Python scalar for the other argument, so this can just be -1.

The file already has the right pattern for this — _get_window_topk_idxs_cached / _get_compress_topk_idxs_cached. Adding a third @lru_cache'd helper would make the causal mask a lookup:

@lru_cache(maxsize=8)
def _get_compress_causal_mask_cached(
    ratio: int, seqlen: int, n_compressed: int, device_str: str
) -> torch.Tensor:
    """Additive causal mask over compressed positions, [seqlen, n_compressed], -inf = invalid."""
    compressed = torch.arange(n_compressed, device=device_str).unsqueeze(0)
    positions = torch.arange(1, seqlen + 1, device=device_str).unsqueeze(1)
    return torch.where(compressed >= positions // ratio, float("-inf"), 0.0)

then in forward:

                causal_mask = _get_compress_causal_mask_cached(
                    self.compress_ratio, sq, n_compressed, str(x.device)
                ).unsqueeze(0).expand(b, -1, -1)  # [b, sq, n_compressed]

and line 887-889 becomes:

                compress_topk_idxs = torch.where(valid, topk_indices_compressed + offset, -1)

Note that n_valid_per_pos would then need to come from a small cached arange too, rather than reusing the positions local — which is itself a readability improvement, since positions is currently defined for the mask and then reused 45 lines later for an unrelated validity check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d02991d. The compressed causal mask and per-position valid counts now use bounded @lru_cache helpers keyed by shape/device inputs; the cached mask is batch-expanded as a view, and torch.where uses the Python scalar -1. Unit coverage verifies mask/count semantics and cache reuse.

@svcnvidia-nemo-ci svcnvidia-nemo-ci added the Final Review PR is in the "final review" stage label Aug 18, 2026
@Phlip79
Phlip79 requested a review from guihong-nv August 19, 2026 00:08
@guihong-nv

guihong-nv commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

@FDecaYed I think we can move the files under megatron/core/transformer/experimental_attention_variant to megatron/core/ops/attention/dsa,mla (depending on if the file is for dsa or mla) or megatron/core/transformer/experimental_attention_variant for now (we split them later). Please also add some tags that clarify if a kernel is deterministic or not; it's okay to put an unknown here.

Signed-off-by: Deyu Fu <deyuf@nvidia.com>
@FDecaYed

Copy link
Copy Markdown
Contributor Author

/ok to test d02991d

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

Labels

complexity: high deepseekv4 DeepSeek V4 PRs Final Review PR is in the "final review" stage Run functional tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants