Repository navigation
[dev] Fix fused MLA RoPE key/value split for non-power-of-two head dims - #7803
Conversation
With apply_rope_fusion=True, MLASelfAttention rotates the RoPE part of the key and splits the up-projected key/value through fused_apply_mla_rope_for_kv (fused_mla_rope_kv_split), with k_dim = qk_head_dim, v_dim = v_head_dim and emb_dim = qk_pos_emb_head_dim. Its Triton kernels cannot be compiled when one of these widths is not a power of two. GLM-5's head dims (qk_head_dim 192, v_head_dim 256, qk_pos_emb_head_dim 64) hit this: the first training forward pass of an MLA layer with RoPE fusion fails with "triton.compiler.errors. CompilationError: arange's range must be a power of 2". Root cause: _mla_rope_fwd_kv_split_kernel and _mla_rope_bwd_kv_split_kernel take k_dim, v_dim and emb_dim as tl.constexpr and address the key channels, the value channels and the rotary pairs with tl.arange(0, k_dim), tl.arange(0, v_dim) and tl.arange(0, emb_dim // 2), i.e. with tiles of [BLOCK_H, k_dim], [BLOCK_H, v_dim] and [emb_dim // 2]. Triton tensor shapes must be powers of two, and tl.arange rejects any other extent when the kernel is compiled, at its first launch. For GLM-5 at TP 8, kv is [tokens, 8, 192 + 256] and k_pos_emb is [tokens, 1, 64]: the value tile (256) and the rotary pairs (32) are powers of two, but 192 = 3 * 64 is not, so the forward kernel fails at "k_in_off = kv_off + tl.arange(0, k_dim)[None, :]" and the backward kernel at "dk_out_off = dkv_off + tl.arange(0, k_dim)[None, :]". DeepSeek-V3-style dims (128 + 128 with 64 rotary channels) are all powers of two, so the limit did not show up before. The other kernels of the file work with GLM-5's dims: the packing kernels of fused_mla_rope_concat already pad their extents to powers of two and mask the rest, and the in-place query kernels skip the no-RoPE channels with a runtime offset, so their only head-width extent is emb_dim // 2 = 32. How it shows up: a hard failure, not a silent one. The error is raised inside the Triton compiler during the first forward pass, after the model has been built; no configuration check catches it earlier. The only workaround is to turn RoPE fusion off (--no-rope-fusion, or apply_rope_fusion=False), which runs the unfused PyTorch path instead. When it triggers: in training (MLA RoPE fusion is not supported in inference) with the standard MLA attention module, MLASelfAttention, apply_rope_fusion=True (the Megatron-LM training default with --position-embedding-type rope) and should_use_fused_mla_rope true (rope_type yarn, or rope with rotary_percent 1.0), whenever qk_head_dim or v_head_dim is not a power of two. These are per-head widths, which tensor, context and pipeline parallelism do not change, so GLM-5's dims fail at every parallel layout and in both the SBHD and the packed THD format. DSA layers (experimental_attention_variant="dsa", GLM-5's own configuration) are built as AbsorbedMLASelfAttention, which packs the key with fused_mla_rope_concat and never calls the key/value split, so they are not affected. How it was found: while timing the fused MLA RoPE kernels on GLM-5 shapes, the key/value split with GLM-5's head dims did not compile. Fix: the launches pass K_BLOCK, V_BLOCK and ROT_BLOCK, the next powers of two of k_dim, v_dim and emb_dim // 2, and the kernels range over them and mask the lanes past each width, as fused_mla_rope_concat already does. With the rotary width padded too, the key/value split no longer needs any width to be a power of two. (The in-place query kernels, which MLA calls with the same rotary width, still need emb_dim // 2 to be one, which the 64 rotary channels of DeepSeek-V3 and GLM-5 satisfy; this change leaves them alone.) Masked lanes are neither loaded nor stored, and they never mix with valid lanes: the arithmetic is elementwise along the channel axis, and the backward's only reduction (the rotary gradient summed over the heads) runs along the head axis. On GLM-5's widths the new kernels give bitwise the same key, value and gradients as the old kernels on the same data zero-padded to 256 + 256, at every BLOCK_H. The masks are built only for padded widths (a constexpr choice), so power-of-two widths compile to the same code as before: for DeepSeek-V3's dims, the small dims of the MLA unit tests and 64 + 512 at one head, both kernels, SBHD and THD, both remove_interleaving values and every BLOCK_H of the autotuners, the PTX and the SASS are identical (256 of 256 variants), and the results are bitwise unchanged. Building the masks unconditionally would not: it changed the SASS of 50 of these variants, including the bf16 FMA contraction of the rotary arithmetic in 6 of them. Cold-L2 kernel times on B200 (a 1 GiB write before every call, kineto, six processes): with DeepSeek-V3's dims at TP 1 and TP 8, forward and backward, at every BLOCK_H and with the autotuned choice, the median time changes by -0.04% to +0.07%. With GLM-5's dims, the fused key/value split now takes 303 us forward and 299 us backward at TP 1 (64 heads, 16384 tokens), and 152 and 153 us at TP 8 (8 heads, 65536 tokens). That is about 6.7 TB/s, the effective bandwidth DeepSeek-V3's dims reach, and 6.3 to 7.1 times faster than the unfused PyTorch path that apply_rope_fusion=False runs. Add a GPU test that runs the key/value split forward and backward against the unfused PyTorch path with GLM-5's head dims (8 heads, as at TP 8) and with key, value and rotary widths that all need padding (96, 160 and 48), in SBHD and THD and for both remove_interleaving values. All 8 cases fail with the compile error before this change and pass after it. Co-authored-by: Yangshen Deng <47519522+TKONIY@users.noreply.github.com> Signed-off-by: Ziqi Yin <ziqi003@e.ntu.edu.sg>
|
/ok to test 40b1cd3 |
|
/review model=claude mode=strict |
svcnvidia-nemo-ci
left a comment
There was a problem hiding this comment.
Strict review passed: no significant issues found. LGTM.
Findings: 0 critical, 0 important, 0 suggestions.
Scope: This was a broad, strict review of both changed files. I read the two key/value split kernels and their launches in fused_mla_yarn_rope_apply.py and the test changes in test_mla_yarn_rope_apply.py. I also checked which files use these functions. I can't run tests or compile kernels here, so everything below comes from reading the source.
Checks on the kernel changes (reading only):
- Out-of-bounds accesses. Every padded lane that would read or write past its real width is masked:
k_maskcovers the key lanes, which would otherwise spill into the value channels.v_maskcovers the value lanes, which would otherwise spill into the next head.x_maskcovers the rotary stores, wherex_right_off = x_left_off + emb_dim // 2would otherwise write pastk_dim + emb_diminto the next head.rot_maskcovers thecos/sin/k_pos_embloads and thedK_POS_EMBstores.- All offsets still use the real
k_dim/v_dim/emb_dim.
- Backward reduction. The only reduction (L1269-L1270) sums over the head axis. Padded columns hold undefined values, but they never mix with valid columns and are never stored. Accumulation stays in fp32 and is cast to the
dEMBdtype, as before. - Power-of-two dims compile to the same kernel.
ROT_BLOCK == emb_dim // 2,K_BLOCK == k_dimandV_BLOCK == v_dimare comparisons between constexprs, so thex if c else yandrot_mask is Nonechoices are made at compile time. For power-of-two widths,maskis used unchanged and thecos/sin/k_pos_embloads stay unmasked, so DeepSeek-V3-style dims build the same kernel as before. Inside the runtimeif pid_head == 0:block,rot_maskandx_maskare only created and used locally. The variable that was already used before the block (mask) is reassigned with the same[BLOCK_H, 1]i1 type as before. - Compatibility. The public API of
fused_mla_rope_kv_split/fused_apply_mla_rope_for_kvis unchanged, and so are the autotuner configs and key.K_BLOCK,V_BLOCKandROT_BLOCKare derived from values already in the key. The private kernels are launched only from_FusedMLARoPEKVSplit. Other users of this file (cp_utils.py,csa.py,deepseek_v4_hybrid_attention.py) import only the in-place and out-of-place helpers. multi_latent_attention.py goes through the alias, which is unchanged. - New identifiers.
K_BLOCK,V_BLOCK,ROT_BLOCK,k_mask,v_mask,rot_mask,x_maskand_check_fused_mla_rope_kv_splitare all actually used; none is dead.
Tests: The existing _test_fused_mla_rope_kv_split keeps its 32 × (128 + 128) / 64 case. The new parametrized test uses the same class-level markers as its neighbours (experimental, internal, CUDA and torch-version skips). Between them, the two new parameter sets exercise padding of all three widths in SBHD and THD, with both remove_interleaving values.
Context, not counted as findings:
- Two head-mask issues in these kernels predate this PR. The block-relative head mask is wrong when
BLOCK_Hdoesn't divide the head count, and the rotary-gradient loads have noother=0.0. The PR description acknowledges both and leaves them to the separate head-mask PRs; this PR doesn't make them worse. - The MLA query in-place kernels still require
emb_dim // 2to be a power of two. The description already calls this out of scope. - The rubric's bit-exact kernel replay test requirement doesn't apply to this branch. The base
devtree has neithertests/unit_tests/determinism/kernels/nortools/check_kernel_determinism_coverage.py. - The rubric also points to a
style-guide.mdthat doesn't exist in the base tree, so no style findings came from it.
Overall risk: low. The change is limited to two kernels and only affects head dims that are not powers of two, which previously failed to compile.
Note: formal approval is not available for this request, so this review is posted as a comment only.
Review generated by claude
Thanks @TKONIY for the contribution to this PR!
Summary
With
apply_rope_fusion=True,MLASelfAttentionbuilds its key and value with the fused MLA RoPEkey/value split,
fused_apply_mla_rope_for_kv. This is an alias offused_mla_rope_kv_splitinmegatron/core/fusions/fused_mla_yarn_rope_apply.py. Its two Triton kernels compile only whenqk_head_dim,v_head_dimand half ofqk_pos_emb_head_dimare powers of two.GLM-5's MLA head dims are
qk_head_dim192,v_head_dim256 andqk_pos_emb_head_dim64. Withthem, the first training forward pass of an MLA layer stops with:
This PR pads the three widths to the next power of two inside the two kernels and masks the padding
lanes, as
fused_mla_rope_concatin the same file already does. The masks are built only when awidth is actually padded. Power-of-two configurations, such as DeepSeek-V3's, therefore compile to
exactly the same kernels as before: identical PTX and SASS. A new GPU test covers GLM-5's dims and a
case in which all three widths are padded.
Problem
MLASelfAttention.get_query_key_value_tensors(multi_latent_attention.py) takes the fused pathwhen
should_use_fused_mla_rope(config)is true:With GLM-5's head dims, this call raises the
CompilationErrorabove instead of returningkeyandvalue. The query RoPE just before it (fused_apply_mla_rope_for_q) works with the same dims.Nothing checks the dims beforehand.
Reproduced on
devatac100f773(B200, Triton 3.6.0, torch 2.11):fused_mla_rope_kv_splitandfused_apply_mla_rope_for_kvwith GLM-5'sdims, at 64 heads (TP 1) and 8 heads (TP 8), in SBHD and in THD format: the forward kernel fails at
line 62 of
_mla_rope_fwd_kv_split_kernel._FusedMLARoPEKVSplit.backwardcalled on its own failsat line 54 of
_mla_rope_bwd_kv_split_kernel,dk_out_off = dkv_off + tl.arange(0, k_dim)[None, :].MLASelfAttention(Transformer Engine spec, bf16, 8 heads) with GLM-5's head dims andapply_rope_fusion=True, in SBHD and in packed THD format: the forward pass fails inqkv_up_proj_and_rope_apply, at thefused_apply_mla_rope_for_kvcall.Root cause
Mechanism
The key/value split kernels take the three widths as
tl.constexprand build their tiles withtl.arangeover them:The backward kernel has the same pattern:
dk_out_off,dv_out_off,dk_in_off,dv_in_off, andthe rotary accumulators
tl.zeros((BLOCK_H, emb_dim // 2)).Triton tensor shapes must be powers of two.
tl.arange(0, n)checks this when the kernel iscompiled, which happens at its first launch (here, the autotuner's first benchmark run). For any
other
nit raisesValueError("arange's range must be a power of 2"), which Triton reports as aCompilationError.Worked example: GLM-5 at TP 8. The inputs are
kv[tokens, 8, 192 + 256]andk_pos_emb[tokens, 1, 64]; the outputs arekey[tokens, 8, 192 + 64]andvalue[tokens, 8, 256].tl.arange(0, k_dim)tl.arange(0, v_dim)tl.arange(0, emb_dim // 2)So the forward kernel fails at its key tile, and so does the backward kernel. DeepSeek-V3-style dims
are all powers of two, which is why this has not shown up before.
The other kernels of the file handle GLM-5's dims (checked on
ac100f773, forward and backward):fused_mla_rope_concat(absorbed MLA) already pads its widths withNOPE_BLOCKandROT_BLOCKand masks the rest.
fused_mla_rope_inplace: the MLA query, 64 x (192 + 64), and the DSAindexer query and key) skip the channels without RoPE with a runtime offset; when the backward
copies them into
out=, it already pads. Their only unpadded head-width extent isemb_dim // 2= 32.How it shows up
CompilationErrorfrom inside the Triton compiler.should_use_fused_mla_ropelooks only atapply_rope_fusion,rope_typeandrotary_percent.--no-rope-fusion, orapply_rope_fusion=False),which runs the unfused PyTorch path instead. For GLM-5's shapes that path is several times slower;
see Performance.
When it triggers
All of the following:
MLASelfAttention. This includes itsmla_down_proj_fusionsubclassFusedMLASelfAttentionand the MLA layers of hybrid models, whichshare
get_query_key_value_tensors.inference_context is None.should_use_fused_mla_rope(config)is true:apply_rope_fusion=Trueand eitherrope_type"yarn"or"rope"withrotary_percent1.0. In the Megatron-LM training scripts,apply_rope_fusionis on by default with--position-embedding-type rope(MLA then usesrope_type"yarn"unless--rope-typesays otherwise);--no-rope-fusionturns it off, andany other
--position-embedding-typeexceptmropeturns it off too.qk_head_dimorv_head_dimis not a power of two, as with GLM-5'sqk_head_dim192.These are per-head widths, and parallelism never changes them:
kvis[tokens, 64 / TP, 192 + 256]forGLM-5, so
[tokens, 64, 448]at TP 1 and[tokens, 8, 448]at TP 8.activations outside attention; inside it,
kvcovers all the tokens of the CP rank.The head width of every tile stays 192 (key), 256 (value) and 32 rotary pairs. GLM-5's dims
therefore fail at every parallel layout, and in both the SBHD and the packed THD format.
Not affected: DSA. With
experimental_attention_variant="dsa"(GLM-5's own configuration), theattention module is
AbsorbedMLASelfAttention. It applies RoPE throughfused_mla_rope_concatandnever calls the key/value split. So the failure hits GLM-5's head dims on the standard MLA module,
for example a GLM-5-shaped model trained with dense MLA attention.
How it was found
It was found while timing the fused MLA RoPE kernels on GLM-5 shapes: the key/value split case with
GLM-5's head dims did not compile.
Fix
The two launches in
_FusedMLARoPEKVSplitpass three new constexprs:K_BLOCK,V_BLOCKandROT_BLOCK, the next powers of two ofk_dim,v_dimandemb_dim // 2(
triton.next_power_of_2).Both kernels range over these blocks instead of the widths and mask the lanes past each width. All
offsets still use the real widths.
The masks cover every access whose extent is a padded width:
cos/sinandk_pos_embloads, the key and value loads and stores, and therotary stores;
dK/dVloads, thedKVstores, the per-head rotary loads and thedK_POS_EMBstores. The rotary accumulators become
[BLOCK_H, ROT_BLOCK].Why it is correct and complete.
tl.arangein the two kernels now has a power-of-two extent:K_BLOCK,V_BLOCK,ROT_BLOCK, orBLOCK_Hfrom the autotuner configs. Any width compiles.memcheck, with the caching allocator off so that every tensor is its own allocation, reports no
errors on padded widths at every
BLOCK_H.backward's only reduction, the rotary gradient summed over the heads, runs along the head axis, so
a padded column never reaches a stored one.
widths (and others), the new kernels give bitwise the same key, value and gradients as the old
kernels on the same data zero-padded to powers of two (192 + 256 run as 256 + 256), at every
BLOCK_H.to be a power of two.
Why the masks are built only for padded widths. An unconditional mask folds to "true" for
power-of-two widths, but it still turns the unmasked loads of
cos,sinandk_pos_embintopredicated loads, and the compiler then schedules the kernel differently. Compiled for B200, an
always-masked variant changed the SASS of 50 of the 256 power-of-two variants checked below. In 6 of
them (DeepSeek-V3 dims, forward kernel,
BLOCK_H64 and 128), it also changed the bf16 FMAcontraction of the rotary arithmetic (
HFMA216 -> 15,HMUL24 -> 5), which can change resultsbitwise. Building the masks only for padded widths keeps power-of-two kernels identical.
What it does not change.
with 64 rotary channels at 128 and 16 heads, the MLA unit tests' 16 + 16 with 8 at 8 heads, and
64 + 512 with 64 at 1 head; forward and backward kernels; SBHD and THD; both
remove_interleavingvalues; every
BLOCK_H). Their outputs are bitwise the same as before (96 of 96 forced-BLOCK_Hruns), and so is their speed.
whose
qk_pos_emb_head_dim / 2is not a power of two would still fail there. The 64 rotarychannels of DeepSeek-V3 and GLM-5 give 32.
Tests and results
New GPU test
tests/unit_tests/fusions/test_mla_yarn_rope_apply.py::TestFusedApplyMLARope::test_kv_split_non_power_of_two_head_dims,with the same markers as its neighbours (
experimental,internal, skipped without CUDA or withtorch < 2.5):
glm5-tp8: 8 heads,qk_head_dim192,v_head_dim256,qk_pos_emb_head_dim64;k96-v160-rope48: 4 heads, with the key, value and rotary widths (96, 160 and 24 pairs) allpadded.
remove_interleavingFalse and True: 8 cases.path (
apply_rotary_pos_embplus split and concat, with autograd), using the bf16 tolerances ofthe file.
_test_fused_mla_rope_kv_splitnow hands its body to_check_fused_mla_rope_kv_split(input_format, remove_interleaving, num_heads, k_dim, v_dim, emb_dim). The helper keeps its signature and its DeepSeek-V3 dims, so the existing test isunchanged.
All numbers below: B200, Triton 3.6.0, torch 2.11.0+cu130; every run is a fresh pytest process
(
python -m torch.distributed.run --nproc-per-node 1 -m pytest ...).ac100f773+ new testCompilationError: arange's range must be a power of 2(glm5-tp8at the key tile,k96-v160-rope48at the rotary tile)Existing tests, compared test by test before and after:
test_mla_yarn_rope_apply.pyandtest_csa_cp_utils.py(aremove_interleaving=Truecaller of the same file).ac100f773)-m "not experimental and not flaky_in_dev")--experimental -m "experimental and not flaky_in_dev"--experimental -m flaky_in_devNo test changed outcome.
Additional checks, outside the unit tests:
MLASelfAttentionwith GLM-5's head dims (8 and 64 heads, dropout off) nowruns forward and backward with
apply_rope_fusion=True, in SBHD and THD, withropeandyarnRoPE. The output and every gradient match the same module and inputs with
apply_rope_fusion=Falseto within 0.72% of their largest value. The fused path keepscos/sin in bf16, so the two paths differ by bf16 rounding; DeepSeek-V3-style dims on unmodified
devdiffer by the same amount (0.72%).BLOCK_Hforced. 8 sets of dims × SBHD/THD ×remove_interleaving× 8BLOCK_Hvalues= 256 runs, compared with an fp32 reference: all pass. The dims include GLM-5's at 8 and 64 heads,
and sets in which the key, the value or the rotary width needs padding.
They use at most 60% of it, the same as the unmodified kernels on power-of-two dims.
same key, value and gradients as the old kernels on the same data zero-padded to powers of two.
This holds in 160 of 160 runs: 192 + 256 at 8 and 64 heads, 96 + 160, 72 + 48 and 192 + 128, each
in both layouts, for both
remove_interleavingvalues and everyBLOCK_H.BLOCK_H, forward and backward, with thecaching allocator off: 0 errors.
Performance
Cold L2 kernel times on B200:
Power-of-two dims: unchanged. The code is identical, so this only confirms that timing is too.
GLM-5's dims: now fused. The old kernels do not compile, so the comparison is with the path
that
apply_rope_fusion=Falseruns: split,apply_rotary_pos_embon the sharedk_pos_emb,expand, concatenate, contiguous.
effective DRAM bandwidth as DeepSeek-V3's dims at the same TP: 6.6-6.7 TB/s.
BLOCK_H=128, spills registers with GLM-5's 256 + 256 lanes per head,as the old kernels do for 256 + 256 widths. It is far slower than the best configuration (for
example 935 vs 152 us for the TP 8 forward), and the autotuner never picked it.
Risk and compatibility
and speed do not change.
(
BLOCK_H=128, 256 + 256 lanes per head) spills registers; the autotuner does not pick it (seePerformance).
constexprconditional expressions (
x if CONSTEXPR_COND else y,rot_mask is None), as the file alreadydoes for
position_ids is not None.Notes for reviewers
The constexpr choice between "no mask" and "mask" is deliberate. An always-on mask is simpler but
changes the compiled power-of-two kernels, including the FMA contraction in some of them (see Fix).
The test helper is extended below its first lines rather than given new parameters. [dev] Fix a race in in-place fused MLA RoPE with remove_interleaving=False #7789 (the
in-place RoPE race fix) adds its test right above
_test_fused_mla_rope_kv_split, and this waythe two PRs do not touch neighbouring lines.
Three other pending changes touch the same two files: [dev] Fix fused MLA RoPE for inputs of 2**31 or more elements #7787 (64-bit row offsets for inputs of
2**31or more elements), [dev] Fix a race in in-place fused MLA RoPE with remove_interleaving=False #7789, and [dev] Use binary search for the THD row lookup in fused MLA RoPE #7806 (a binary search for the THD row lookup). All fourapply in any order without conflicts (
git amwithout-3,git cherry-pick,git merge-tree), and theresult does not depend on the order. On top of the 64-bit offset fix, this PR's kernels are again
identical for power-of-two widths.
The open head-mask fixes Offset the per-head bounds mask by the head block in fused MLA RoPE #6890, Fix out-of-bounds head masks in fused MLA YaRN RoPE kernels #7104 and test(mla): check every BLOCK_H candidate of the fused MLA RoPE kernels on a partial head block #7770 (against
main;BLOCK_Hnot dividing the headcount) rewrite the
mask = ...line just above this PR's first change in both key/value splitkernels and in the backward's per-head loop. Offset the per-head bounds mask by the head block in fused MLA RoPE #6890 and test(mla): check every BLOCK_H candidate of the fused MLA RoPE kernels on a partial head block #7770 also add
other=0.0to the fourrotary-gradient loads that this PR re-masks. Whichever lands on
devsecond needs a mechanicalrebase:
mask=x_mask, other=0.0.k_mask,v_maskandx_maskare built onmask, so they inherit the new head mask. This waschecked on a port of test(mla): check every BLOCK_H candidate of the fused MLA RoPE kernels on a partial head block #7770 to
devwith this PR on top: everyBLOCK_Hmatches an fp64 referencewith partial head blocks (12 and 127 heads) and padded widths, and the kernel tests of both PRs
pass. This PR's tests use 8 and 4 heads, so they do not depend on the head-mask fix.
Pre-checks
test_kv_split_non_power_of_two_head_dims)with the repository settings:
blackandisortleave them unchanged,ruffpasses,pylintrates them 10.00/10, andgit diff --checkis clean)Linked issue: none (bug fix).
@hxbai @yuzhongw-nvidia @buptzyb, could you please take a look?