fix(deepseek-v4): correct V4-Flash attention math, wire the MoE balance loss - #930
Open
lhzhang333 wants to merge 14 commits into
Open
fix(deepseek-v4): correct V4-Flash attention math, wire the MoE balance loss#930lhzhang333 wants to merge 14 commits into
lhzhang333 wants to merge 14 commits into
Conversation
_attention_scale() multiplied the plain 1/sqrt(head_dim) temperature by the
YaRN m_scale of the layer's RoPE, which on the 41 compressed layers (Flash,
rotary_scaling_factor=16) inflated every logit by ~1.277x.
The released DeepSeek-V4-Flash inference/model.py sets
self.softmax_scale = self.head_dim ** -0.5
for both the core attention and the indexer, with no magnitude factor: V4
keeps logits in range through the Q / KV RMSNorms instead. Megatron-LM's
reference does the same (softmax_scale = v_head_dim ** -0.5, mscale forced
to 1.0). The YaRN frequency interpolation on inv_freq is untouched.
The HCA inline reference encoded the same bug, so it is corrected here too,
and a new parametrised test pins the temperature to 1/sqrt(head_dim) on the
dense / CSA / HCA branches using a non-unit yarn_factor so the assertion
cannot go vacuous.
Two defects in the compressed (CSA / HCA) RoPE branch: 1. Position basis. _build_compressed_pool() rotated compressed entry s at the bare block index s, while the queries are rotated at their original token positions. The two sides therefore lived in different coordinate systems and the relative phase was wrong by a factor of compress_ratio. Compressed entry s covers the window starting at original token s * ratio and must be rotated there -- inference/model.py slices freqs_cis[:cutoff:ratio] and Megatron-LM slices cos[:total:ratio], both landing on s * ratio. RoPECache.forward_arange() grows a `stride` argument (folded into the memo key) so the compressed table stays cached across steps and layers. 2. Base. V4-Flash inherited compress_rope_theta=160000, which is the V4-Pro value. The released DeepSeek-V4-Flash inference/model.py ships compress_rope_theta=40000.0, matching Megatron-LM's Flash recipe (csa_compress_rotary_base: 40000) and its flash/pro parity fixtures. deepseek_v4_flash.yaml now pins 40000; base.yaml keeps 160000 for Pro. Tests: the HCA inline reference used block indices too and is corrected; a new parametrised test pins the pool phase to s * ratio for ratio 4 and 128 with P > 1 (the two conventions coincide at P == 1, so the old tests could not see this); forward_arange gains stride parity / cache-key coverage; and the yaml gate asserts the per-variant base.
V4 shares one latent between K and V, so the values entering the softmax are
already RoPE-rotated and the core attention output
o_t = sum_s P[t,s] * R(s) v_s
carries absolute position information. The model expects relative positions,
which is why the released inference/model.py rotates the output tail by -t
o = sparse_attn(q, kv, self.attn_sink, topk_idxs, self.softmax_scale)
apply_rotary_emb(o[..., -rd:], freqs_cis, True) # inverse == conj
o = o.view(bsz, seqlen, self.n_local_groups, -1) # -> wo_a
right before wo_a, and why the paper (2.3, Partial RoPE) states "we also apply
RoPE with position -i on the last 64 dimensions of each o_{t,i}". Primus went
straight from the attention output into the grouped-O projection, so all 43
layers fed wo_a a tensor carrying absolute positions. A learned linear cannot
undo a per-token rotation, so this is not absorbable by training.
The inverse rotation is R(t)^T, i.e. the same kernel with a negated sine, so
the existing eager and Triton paths are reused unchanged (the Triton forward
uses the identical rot_even/rot_odd formula and its backward is derived from
the same cos/sin, so autograd stays correct). It must also use the *layer's*
RoPE base, matching how Q/KV were rotated.
Both the dense core_attention fast path and the generic dense/HCA/CSA path now
exit through one _project_output() helper so no branch can reach wo_a without
de-rotating, and the de-rotation is out-of-place because the attention backward
retains the core output.
Tests: both inline references gain the de-rotation; new tests cover the R(-t)
round trip, the wiring on all three branches (asserting the result differs from
the un-derotated projection), and the out-of-place contract.
run_deepseek_v4_flash.sh defaulted USE_V4_FP8_INDEXER to True, overriding both run_deepseek_v4.sh (False) and deepseek_v4_base.yaml (use_v4_fp8_indexer: false). So the production Flash recipe silently trained with a fake-quantized indexer while every other surface said otherwise. That is the opposite of the reference: Megatron-LM wraps the CSA compressor and the indexer weight projections in get_fp8_disabled_context so they stay BF16 even when the enclosing layer runs FP8 -- its docstring names "the DeepSeek V4 CSA compressor and indexer" explicitly. The indexer chooses which 512 compressed KV entries each query attends to, so quantization error there does not just perturb a value, it changes the selection. It is also not buying anything: use_fp8_qk only fake-quantizes the QK operands (quantize/dequantize) and the GEMM still runs in BF16, so the knob is pure overhead outside of QAT experiments, where it remains available via the environment variable. Adds a torch-free test that parses the run scripts and pins the default, checks the knob stays overridable, and asserts the Flash launcher does not diverge from the generic one -- the divergence is what hid this.
The CSA lightning indexer selects which index_topk compressed KV entries each query attends to, but argTopK is not differentiable and the forward discarded the scores, so the indexer had no gradient path at all. It was therefore frozen -- which is right when loading an already-trained indexer, but means a from-scratch pretrain selects essentially at random across all 21 CSA layers. Adds the distillation objective the reference uses: KL between the indexer's score distribution and the distribution the real attention places over the same entries (DeepSeek-V3.2 2.1; Megatron's compute_dsa_indexer_loss). This is the sparse variant -- evaluated only on the selected entries, matching Megatron's Flash recipe (dsa_indexer_use_sparse_loss: true). Because those entries are already gathered per query the computation stays in [B, S, K] and never materialises the dense [B, H, S, P] score tensor. Wiring notes: * v4_indexer_distill_loss_coeff gates everything and defaults to 0.0, so this commit is a no-op for existing runs: no loss, indexer still frozen, params still excluded from the grad buckets (overlap_grad_reduce keeps working). A positive coefficient enables the loss and unfreezes the indexer together, so the two can never disagree. Megatron's Flash recipe uses 1e-2. * The loss is attached with V4IndexerLossAutoScaler (the MoE-aux-loss / MTPLossAutoScaler trick) onto `pool`, which every CSA backend consumes, so the aux gradient is seeded whichever kernel the layer dispatches to instead of threading a second return value through all ten of them. * Rows with no legal compressed entry (early queries) are neutralised before the softmax, so an all-masked row yields 0 rather than NaN. Tests cover the loss in isolation (zero KL for a perfect indexer, positive and linear in the coefficient, finite on fully-masked rows, produces gradients), the auto-scaler contract, and the wiring end to end: frozen at coeff 0, trainable and receiving non-zero gradients through a real CSA forward/backward at coeff > 0, and inactive in eval.
lhzhang333
requested review from
Xiaoming-AMD,
limou102 and
wenxie-amd
as code owners
July 28, 2026 11:56
…erence
Six defects in the attention path, all found by re-reading the CSA side of the
open-source reference (the previous batch had only consulted the DSA side).
1. The index score I_{t,s} was the bare sum over heads of w_h * ReLU(q_h . k_s),
missing the sqrt(index_n_heads * index_head_dim) temperature the reference
applies -- it scales the per-head weights by index_n_heads ** -0.5 and folds
in the indexer's own index_head_dim ** -0.5 on the way into the loss. At the
V4-Flash widths that is a factor of ~90.5. top-k is invariant under a positive
constant, which is exactly why it went unnoticed: the forward output is
identical either way, and it only bites once the scores enter a softmax --
which is what the distillation loss does, where a distribution ~90x too sharp
makes the KL gradient unusable.
2. The distillation loss was not one-directional. Neither the KL target (the
attention queries and the compressed pool) nor the indexer's own input was
detached, so the loss rewarded the main attention's Q projection and the
compressor for becoming easier to predict, and leaked through the indexer's
mini-compressor into every layer below. The reference detaches both the query
and the key on the way in, and feeds the indexer a detached hidden state.
3. The auxiliary loss scale had no driver: set_loss_scale was never called, so
the seeded gradient stayed at 1.0. Under gradient accumulation that makes the
effective coefficient num_microbatches times too large and ignores the grad
scaler. The pipeline schedule already installs exactly this quantity once per
microbatch for the MoE auxiliary loss, and updates it in place, so follow that
rather than keeping a second copy that can drift.
4. The loss was computed but never surfaced, so enabling it gave no way to see
whether it was falling, flat or NaN. It now goes through the MoE aux-loss
tracker. Every V4 layer reports, with indexer-less layers contributing an
explicit zero -- not redundancy: the tracker is reduced across pipeline ranks
over whatever keys each rank holds, so a key present only on the ranks owning
a CSA layer would make the collective diverge. The head-sum reduction is also
made explicit; V4 gathers its column-parallel Q projection so the sum is
already complete, but the group is resolved when the shapes say otherwise.
5. The indexer scored raw projections: no positional information and no Hadamard
rotation on either operand. The reference applies partial RoPE at the
compressed-branch base (queries at their token positions, compressed keys at
s * compress_ratio, matching the main pool) then a normalised Hadamard
rotation -- indexer-only; the main compressor is deliberately unrotated. The
rotation is orthogonal so it leaves the inner product untouched in exact
arithmetic; what it buys is spreading channel energy so nothing dominates the
low-precision QK product. Since the reference's Hadamard extension is not
guaranteed present, the new module prefers it and otherwise multiplies by a
cached Sylvester matrix. This changes which entries the selector picks, and so
the training trajectory; it is required for loading the released checkpoint.
6. ape and attn_sink are FP32 in the released checkpoint and both feed a softmax
directly, but were ordinary parameters that the blanket module.bfloat16() took
down to BF16 -- and attn_sink was additionally cast to Turbo's dtype. Newer
upstream releases have a keep-in-FP32 helper plus Float16Module support; the
version pinned here has neither, so the contract is implemented module-side,
restoring from a saved FP32 copy rather than casting downgraded values back.
This also removes a gradient dtype mismatch: the triton_v2 backend already
asserts an FP32 sink and the sparse-MLA adapter returns the gradient at the
promoted dtype.
Also stops PRIMUS_V4_FP8_ATTN_PROJ from quantizing the selector as a side effect
-- picking which entries to read and perturbing a value are separate decisions,
and the reference keeps both compressor and indexer projection in high precision
under an enclosing FP8 autocast. The opt-in moves to its own knob, default off.
And routes the O-projection inverse RoPE through the same in-kernel cos/sin path
Q/KV use, via a new INVERSE flag that negates the angle. Across 43 layers the old
path cost three extra launches, a materialised cos/sin tensor and a contiguous
copy per layer. cos is even and sin odd, so it is the same rotation.
Verified on 8xMI355X: 581 passed / 81 skipped / 0 failed over the full
deepseek_v4 suite (was 484 passed / 81 skipped; the delta is new cases, and the
skip count is unchanged). See output/0729_fix/dsv4_fixes_0729.md.
Two configuration keys that had been declared for a long time without anything
reading them.
moe_router_load_balancing_type: seq_aux_loss with moe_aux_loss_coeff: 0.001 --
the paper pairs the auxiliary-loss-free expert bias with a slight sequence-wise
balance loss precisely because the bias reacts over many steps and across the
whole batch, so it cannot stop a single sequence from collapsing onto a handful
of experts. Not inherited from the framework's seq_aux_loss: that path runs
through a scores helper that only knows softmax and sigmoid and raises on V4's
sqrtsoftplus. Implemented from the DeepSeek-V3 formula instead, whose
normalisation makes a perfectly balanced sequence score exactly alpha -- which
makes the logged value directly readable and gives a magnitude assertion that is
cheap to check on real shapes. Two deliberate choices: f counts the actual
routing (expert_bias applied), because the paper defines it over the tokens
really dispatched and the point of the loss is real load; P uses the unbiased
normalised affinities, which is what s'_{i,t} denotes and the only part carrying
a gradient. Under sequence sharding the expert counts are reduced so f is global
while P stays local, so each rank differentiates only its own tokens.
moe_shared_expert_overlap: true -- the token dispatcher already carries the
hooks, and they sit in exactly the decomposed calls the V4 MoE forward already
makes: dispatch_preprocess starts the input comm on a side stream,
dispatch_postprocess runs fc1 plus activation, combine_postprocess runs fc2. The
load-bearing detail is what combine_postprocess does last: it adds the shared
output into the combine result. So handing the shared expert over also means the
layer must stop adding it, or it is counted twice; that is what the new helper's
return value gates, with its own test. Falls back to serial when the config does
not ask for it, the dispatcher does not support it, or the shared expert has no
overlap protocol.
Measured on 8xMI355X. The balance loss lands at 1.0003x alpha on a real router at
Flash widths, and exactly alpha / alpha*E/K on synthetic balanced / collapsed
routing -- a magnitude error in the formula would show up immediately. Overlap is
mathematically equivalent: with it off, the loss trajectory falls inside the
run-to-run spread of two overlap-on runs, and it is worth ~1.2% throughput. See
output/0729_fix/dsv4_fixes_0729.md.
compress_rope_theta was pinned to the Flash value of 40000 while rotary_scaling_factor still inherited the Pro value of 16. Those two parameterise the same inv_freq table -- the factor is the YaRN interpolation applied to the low-frequency band of a base-40000 table -- so aligning only the base leaves the low-frequency end off by 4x. The upstream open-source Flash recipe ships them as a pair. Records a known gap rather than papering over it: the released inference/model.py sets original_seq_len = 0, and its precompute_freqs_cis only interpolates when that is positive, so the released model effectively runs with YaRN frequency interpolation off and its rope_factor never takes effect. The pair set here follows the upstream training recipe, which does interpolate; it is not the released checkpoint's effective configuration. Resolving that needs the official config.json to say which one the weights were trained under, so the yaml carries the caveat inline. Full write-up of both batches, including the specific upstream file and line references behind each change, is in output/0729_fix/dsv4_fixes_0729.md (untracked, as output/ is gitignored).
Holding ape / attn_sink at FP32 while the rest of the model is BF16 aborted every GPU with "Memory access fault ... Reason: Unknown" on the first training step of a 4-node / PP4 / EP8 run. The grad buffer does allocate one buffer per (param_dtype, grad_dtype) as intended, but the distributed optimizer layered on top does not follow: it carries five "single dtype supported, for now" guards. Single-node PP1 does not reproduce this, and that is what every unit test and every soak run so far used, so the mechanism is kept available behind PRIMUS_V4_KEEP_FP32=1 rather than removed. Turning it off costs stored resolution only. The precision-aware optimizer keeps FP32 master params either way, so update precision is unchanged, and every consumer already promotes at the use site -- sink.float() in the sparse-MLA adapter and the eager reference, score.float() before the pooling softmax -- so forward precision is unchanged too. The FP32 sink assertions live in the gluon / flydsl_v1 kernels and are satisfied by that existing promotion, not by the parameter's own dtype; an earlier comment attributed them to triton_v2, which is corrected here. A/B on 4 nodes, 10 iterations each: mechanism on -> all 8 GPUs abort at step 1 baseline -> 10/10 clean mechanism off -> 10/10 clean Tests: the pinning suite now opts in explicitly and gains coverage for the default-off path (single parameter dtype across the module, pooling forward still finite, and only "1" enabling it). 589 passed, 81 skipped.
…be on The yaml comment said 0.0 was "correct when loading an already-trained indexer". That is not what the papers do. The V4 training setup introduces sparse attention after a dense phase, starting with a short stage that warms up the CSA lightning indexer, and the reference DSA formulation keeps the KL alignment on through the whole sparse training stage once the rest of the model is unfrozen -- the main attention distribution keeps moving, so the indexer has to keep tracking it. Off is only right when nothing is being trained. Also records two things the comment did not say: 0.0 is an engineering default (it makes enabling the feature an explicit decision, and keeps frozen params out of the grad buckets), and the coefficient here is standing in for a separate learning rate, since the reference gives the indexer its own optimizer with a much larger LR during warm-up while this implementation shares the main one. Comment-only; no behaviour change.
…head tensors Profiling the 4-node run (43 layers, PP4, 32 microbatches, trace on rank 0) showed the distillation loss adding 750 ms of GPU kernel time per iteration at gbs64, and the target branch -- not the indexer being trained -- was most of it: index_elementwise (the pool gather) +126 ms elementwise (dtype promote / mask / mul) ~300 ms fp32 GEMM (the einsum) +45 ms The gather produced [B, S, K, head_dim], 2.1 GB per microbatch at Flash widths, and it was then promoted to fp32 before the GEMM, so the promotion alone moved more bytes than the GEMM it fed. The [B, H, S, K] logits and probs were also materialised in full even though only their head sum is used. The whole target branch is detached, so nothing in it has to survive for backward. That makes three things safe: * run the gather and GEMM at the model dtype and promote only the result, which is head_dim times less traffic -- and closer to the distribution being imitated, since the main attention computes its logits at the model dtype too; * do the head sum inside the loop, so [B, H, S, K] never exists in full; * chunk the query axis, so the gather does not either. Measured on 4 nodes, gbs256, coeff=1e-2, 10 iterations: 10721.7 -> 10434.1 ms/iter (-2.7%), so the loss now costs +17.7% over coeff=0 instead of +20.9%. lm loss at iter 10 moves by 1.7e-6, i.e. unchanged. Chunk size is tunable via PRIMUS_V4_DISTILL_TARGET_CHUNK (0 disables chunking). Unit tests are unaffected: they feed fp32, so "do not promote" is a no-op there. Note what this does NOT address. Roughly 40% of the remaining overhead is nccl kernels getting slower at an unchanged call count, i.e. the loss is serialised into the critical path ahead of the MoE all-to-all rather than overlapping with it. Fixing that needs the target either fused into the CSA kernel that already computes these logits, or moved off the critical path.
pre-commit was never run over this branch, so CI's `pre-commit run --all-files` failed: isort rewrapped three import blocks and black reformatted ten files, every one of them added or touched by this PR. No behaviour change. Mechanical except for one line. black collapsed an implicit f-string concatenation in Indexer.__init__ onto a single line, which reads badly; merged into a single f-string instead -- clearer, and stable under black at line-length 110.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Correctness fixes to the DeepSeek-V4-Flash attention and MoE paths, plus two
config keys that had been declared for a long time without anything reading them,
plus a recipe rebalance for the 4-node run. Every fix is backed by the released
reference (
deepseek-ai/DeepSeek-V4-Flash,inference/model.py), the DeepSeek-V4paper, or the upstream open-source training reference.
Thirteen commits plus one merge, merged up to the tip of
dev/tas/deepseek-v4-phase2(9eea27db). Net diff: 29 files, +4400 / −90 —4 new production modules, 9 new test files. (The thirteenth is an isort / black
pass over the twelve below, with no behaviour change.)
Several of these change model math on almost every layer, so loss curves will
shift and checkpoints trained with the old code are no longer equivalent. That
is the intended effect: the old behaviour did not match the reference.
10524f85m_scalefrom the attention softmax scale70a0fc06compress_rope_theta2255b56f4dbfb5781ae566c5f133beb3c1431ac55b833bf128410e3044c613be7b010cef1458f9e4Part 1 — Attention correctness
1. Drop YaRN
m_scalefrom the softmax scale._attention_scale()multiplied
1/sqrt(head_dim)by the layer's YaRN magnitude factor, inflatingevery logit on the 41 compressed layers by ~1.277x at
rotary_scaling_factor = 16. The reference uses a plainhead_dim ** -0.5forboth the core attention and the indexer. Paper §2.4 gives the reason: V4 RMSNorms
the queries and KV entries directly, so no temperature compensation is needed
(and for the same reason QK-Clip is not used). The YaRN frequency interpolation
on
inv_freqis untouched.2. Rotate compressed KV at original-sequence positions. Two defects in the
compressed (CSA/HCA) RoPE branch. Position basis:
_build_compressed_poolrotated entry
sat positions, while queries are rotated at their originaltoken positions. Entry
scovers the window starting ats*ratio, so the twosides lived in different coordinate systems and the relative phase was off by a
factor of
compress_ratio(4x CSA, 128x HCA). The reference samplesfreqs_cis[:cutoff:ratio];RoPECache.forward_arangegrows astrideargumentfolded into the memo key, so the table stays cached. Base value: Flash
inherited
compress_rope_theta = 160000, which is the V4-Pro value; thereleased Flash
ModelArgsships40000.0.3. De-rotate the attention output before the O projection (blocking). V4
shares one latent between K and V, so the values entering the softmax are already
rotated and the core output
o_t = sum_s P[t,s] * R(s) * v_scarries absoluteposition information. Paper §2.3 applies RoPE with position
−ion the last 64dimensions of each output, right before
wo_a. Primus went straight into thegrouped-O projection, so all 43 layers fed
wo_aabsolute positions — notabsorbable by training, since
wo_ais a learned linear and what is missing is aper-token rotation. All ten attention backends converge to
[B,S,H,head_dim]before the projection, so one insertion point covers them all with no kernel
changes; both exits now go through a single
_project_output()helper so nobranch can reach
wo_aun-derotated.4. Keep the indexer QK in high precision by default.
run_deepseek_v4_flash.shdefaultedUSE_V4_FP8_INDEXER=True, overriding bothrun_deepseek_v4.shand the yaml — that divergence is why nobody noticed. Itshould not be a silent default: the indexer decides which entries each query
attends to, so quantization error there changes the selection rather than
perturbing a value; and it is a fake quant around a BF16 GEMM, so outside QAT
it is pure overhead. Item 6 closes the other half — the indexer's weight
projections were gated on
PRIMUS_V4_FP8_ATTN_PROJ, so enabling FP8 for theattention projections silently quantized the selector too. Now its own knob,
PRIMUS_V4_FP8_INDEXER_PROJ, default off.5. Add opt-in indexer distillation loss.
topkis not differentiable and theforward discarded the indexer scores, so the indexer had no gradient path and was
frozen — a from-scratch pretrain selects at random across all 21 CSA layers. (The
freeze was deliberate: dead params in the grad buckets waste optimizer state and
break the grad-ready-hook invariant, so unfreezing and adding the loss must
happen together.) Adds
KL(attention || indexer)over the selected entries perV3.2 §2.1, sparse (top-k) variant, so the computation stays in
[B, S, K]and never materialises the dense
[B, H, S, P]tensor.v4_indexer_distill_loss_coeff(default0.0, surfaced asPRIMUS_V4_INDEXER_DISTILL_LOSS_COEFF) gates the loss and whether the indexeris trainable, so the two can never disagree;
0.0makes this a complete no-opfor existing runs.
6. Align the V4 attention and CSA indexer with the reference. Item 5 shipped
the loss but left it incomplete in ways that only bite once the coefficient is
positive. Re-reading the CSA side of the reference (item 5 had only consulted the
DSA side) turned up seven defects:
I_{t,s}was the bare head-sumof
w_h * ReLU(q_h · k_s); the reference scales per-head weights byindex_n_heads ** -0.5and folds inindex_head_dim ** -0.5— at Flash widthsa factor of ~90.5.
topkis invariant under a positive constant, which iswhy it went unnoticed; it only bites once the scores enter a softmax, where a
distribution ~90x too sharp makes the KL gradient unusable. Folded into
w_iso all scoring branches inherit it. A parity test cannot see this, so the tests
assert the constant directly.
indexer's input was detached, so the loss also rewarded the main Q projection
and the compressor for becoming easier to predict, and leaked into every layer
below. The reference detaches both. The assertion that matters is negative:
coeff 0 vs 1e3 must give bit-identical gradients outside
indexer.*.set_loss_scalewas never called,so the seeded gradient stayed at 1.0 — under gradient accumulation the
effective coefficient is
num_microbatchestimes too large. Now follows theper-microbatch scale the schedule already installs for the MoE aux loss.
every layer reporting (indexer-less layers report an explicit zero, since a key
present only on CSA-owning ranks would diverge the cross-PP collective).
Does not actually reach the log yet — see "Known gap".
Hadamard rotation. The reference applies partial RoPE at the compressed base
(queries at their own positions, keys at
s * compress_ratio) then a normalisedHadamard — indexer-only; the main compressor stays unrotated. The rotation is
orthogonal so the inner product is unchanged in exact arithmetic; what it buys
is spreading channel energy so nothing dominates the low-precision QK product.
Prefers the reference's Hadamard extension, falls back to a cached Sylvester
matrix. Applied before the scoring dispatch, so eager / Triton-tail /
full-Triton all inherit it. Changes which entries the selector picks, and
therefore the training trajectory; required for loading the released
checkpoint.
ape/attn_sinkwere not FP32. Both are FP32 in the releasedcheckpoint and both feed a softmax directly, but
module.bfloat16()took themdown. Implemented module-side (
keep_in_fp32.py) since the pinned Megatron hasno equivalent, restoring from a saved FP32 copy rather than casting downgraded
values back. Read item 9 first — it crashes at 4 nodes and now ships behind
a flag.
eagerly while Q/KV generate them in-kernel: three extra launches, a materialised
tensor and a contiguous copy per layer, across 43 layers. An
INVERSEflag onthe in-kernel variant recovers it.
Part 2 — MoE
7a. Sequence-wise balance loss.
moe_router_load_balancing_type: seq_aux_loss+moe_aux_loss_coeff: 0.001had nothing reading them. The paperpairs the aux-loss-free expert bias with a slight sequence-wise loss precisely
because the bias reacts over many steps and the whole batch, so it cannot stop a
single sequence collapsing onto a few experts. Not inherited from the framework's
built-in
seq_aux_loss: that path only knowssoftmax/sigmoidand raises onV4's
sqrtsoftplus. Implemented from the DeepSeek-V3 formula:fsums toEandPto 1, so a perfectly balanced sequence scores exactlyalpha — the logged value is directly readable and the magnitude is cheap to
assert.
fcounts the actual routing (the tokens really dispatched);Pusesthe unbiased normalised affinities, the only part carrying a gradient.
7b. Shared-expert / all-to-all overlap.
moe_shared_expert_overlap: truewasdeclared while the shared expert ran serially. The dispatcher hooks sit in exactly
the decomposed calls the V4 MoE forward already makes. The load-bearing detail:
combine_postprocessadds the shared output into the combine result, sohanding the shared expert over also means the layer must stop adding it or it is
double-counted — that is what the new helper's return value gates, with its own
test.
Part 3 — Config and recipe
8. Pair the compressed RoPE base with its YaRN factor. Item 2 pinned
compress_rope_thetato 40000 whilerotary_scaling_factorstill inherited thePro value of 16. Those two parameterise the same
inv_freqtable, so aligningonly the base leaves the low-frequency end off by 4x. This records a known gap
rather than papering over it: the released
inference/model.pysetsoriginal_seq_len = 0and only interpolates when that is positive, so thereleased model effectively runs with YaRN frequency interpolation off. The
pair set here follows the upstream training recipe, not the released
checkpoint's effective configuration; resolving that needs the official
config.json, so the yaml carries the caveat inline.9. Ship the keep-in-FP32 pinning disabled by default. Item 6f is correct in
isolation and aborts every GPU at 4 nodes: on 4N / PP4 / EP8 with
use_precision_aware_optimizer+store_param_remainders, every GPU died withMemory access fault ... Reason: Unknownon the first training step. The samebuild with the mechanism off runs 10/10 clean, and so does the pre-PR baseline.
Root cause: the grad buffer does key on
(param_dtype, grad_dtype)and allocateone buffer per combination — that part of 6f's claim is true — but the distributed
optimizer on top does not follow;
distrib_optimizer.pycarries fiveassert len(gbuf_range_maps) == 1, "single dtype supported, for now."guards. So6f's claim that "mixed parameter dtypes are fine downstream" was wrong, and
wrong in a way no test here could catch: single node with PP1 does not reproduce
it, and that is what every unit test and soak run used.
Turning it off costs almost nothing —
store_param_remainderskeeps FP32 masterparams either way, and every consumer already promotes at the use site, so both
update and forward precision are unchanged. What the mark buys is stored
resolution matching the released checkpoint: checkpoint parity, not training. (A
related correction: the FP32 sink assertions are in the gluon / flydsl_v1
kernels, not
triton_v2as 6f said, and the callers' promotion already satisfiesthem.) Enable with
PRIMUS_V4_KEEP_FP32=1at PP1.10. Correct the yaml comment on the distillation loss. Comment-only. The yaml
said
0.0was "correct when loading an already-trained indexer"; the papers keepthe KL on through the whole sparse stage. Corrected guidance is under item 5.
11. Build the indexer KL target without the dense per-head tensors. Profiling
the 4-node run showed the loss adding 750 ms of GPU kernel time per iteration at
gbs64, and the target branch — not the indexer being trained — was most of it:
pool gather
+126 ms, dtype promote / mask / mul~300 ms, fp32 einsum+45 ms.The gather produced
[B, S, K, head_dim](2.1 GB per microbatch at Flash widths)and was then promoted to fp32, so the promotion alone moved more bytes than the
GEMM it fed.
The whole target branch is detached, so nothing has to survive backward. That
makes three things safe: gather and GEMM at the model dtype, promoting only the
result (
head_dimtimes less traffic, and closer to the distribution beingimitated since the main attention computes its logits at the model dtype too);
head-sum inside the loop so
[B, H, S, K]never exists in full; and chunk thequery axis so the gather does not either. Measured on 4 nodes, gbs256,
coeff=1e-2: 10721.7 → 10434.1 ms/iter (−2.7%), so the loss now costs+17.7% over
coeff=0instead of +20.9%; lm loss moves by 1.7e-6. Chunk sizevia
PRIMUS_V4_DISTILL_TARGET_CHUNK.Not addressed: ~40% of the remaining overhead is nccl kernels getting slower at
an unchanged call count, i.e. the loss is serialised ahead of the MoE all-to-all
instead of overlapping with it.
12. Rebalance the 4-node PP layout.
Et*10|t*11|t*11|t*11mL→Et*10|t*12|t*12|t*9mL: the last stage carries the MTP layer and the loss on topof its decoder layers, so giving it the same 11 as the middle stages made it the
bubble (total is still 43).
PRIMUS_RECOMPUTE_LAYERSnow defaults to0on 4nodes (was hard-coded
3). Both knobs and the 8-node layout now honour anincoming environment variable, so a sweep can override them without editing the
script. MTP=0 variants unchanged; no separate A/B, the E2E numbers below predate
it.
Known gap: the two new auxiliary losses train but are not visible in the log
Both
log_seq_balance_loss(7a) andlog_indexer_distill_loss(6d) start with anif not layer_number: returnsentinel, and on the V4 path it always fires.DeepseekV4HybridLayer.__init__deliberately bypassesTransformerLayer.__init__, so the upstreamself.mlp.set_layer_number(...)callnever runs; the layer computes its own 1-based
layer_numberbut does not forwardit when building the attention and MLP submodules. So
DeepseekV4Attention.layer_numberstays at its0default andDeepseekV4MoE.layer_number— hencelearned_router.layer_number— staysNone.DeepseekV4MoE.set_layer_numberexists but has no production caller; only theunit tests invoke it, which is why the suite is green.
seq_load_balancing_lossis intrack_namesandforce_initialize=Truecreates a zero-filled tracker entry and reduces it, so the field prints
0.0—which looks like perfectly balanced routing.
indexer_distill_lossis not in
track_nameseither, so fixing only the sentinel would still bypassthe cross-PP reduction. Both halves need fixing together.
layer_number, soboth objectives train exactly as measured below. This is a reporting defect,
not a math defect, and it is why the numbers below were read off the loss
tensors rather than a log.
The fix is small (forward
layer_numberat the twobuild_modulesites, add thekey to
track_names) but touches a path shared with the MTP layer, so it is leftout of this PR rather than bolted on unmeasured. First follow-up.
Test plan
Unit tests (8x MI355X, ROCm). Full
deepseek_v4suite: 589 passed, 81skipped, 0 failed, against a pre-PR baseline of 484 / 81 — the delta is new
cases and the skip count is unchanged, so no gate started skipping. Affected
existing tests: 150 passed, 2 skipped.
Nine new test files.
test_v4_parity_suite.pyis split into numerical parity(rebuilds the CSA forward from the module's own parameters with plain matmuls) and
gradient topology (asserts which parameters each objective may and may not
reach) — scale and detach bugs are invisible to a forward-parity test and to a
loss-value assertion alike, and the only thing that catches them is asking where
the gradient went. The rest pin 6a's constant, the Hadamard's orthogonality in
float64, bit-exact FP32 pinning, the balance-loss normalisation, the double-count
guard, and the FP8 knob independence. Two existing inline references encoded the
bugs being fixed and were corrected as part of the fix; the compressed-position
test deliberately uses
P > 1, since the two conventions coincide atP == 1—which is why the previous HCA test could never have caught it.
End-to-end, 1 node x 8 MI355X, seq=4096, GBS=8, turbo backends. Baseline 2
runs, PR 4 runs with byte-identical argument dumps (so they double as a spread
measurement):
Read the spread before the delta: the four identical runs span 322.8–327.4 ms
(±0.7% around their own mean), so a single pair can land anywhere between +0.8%
and +2.3%. From 2000-iteration runs (314.5 / 312.2 / 314.6 ms, ±0.4%), steady
state is 314.5 ms/iter, 852.3 TFLOP/s/GPU. Items 1–5 measured +0.87% on their
own, so item 6 adds ~half a percent: 6g largely cancels 6e.
4 nodes / PP4 / EP8 — cost of the distillation loss, 10 iterations each:
coeff=0coeff=1e-2(pre-11)coeff=1e-2(post-11)+17.7% is much more than the +8.7% measured on one node because that run had 3
CSA layers out of 8 while Flash has 21 out of 43 — the cost lands only on CSA
layers. The main loss barely moves, which is 6b working as intended.
Balance-loss magnitude, measured on real GPU shapes because a magnitude error
in a loss formula passes every shape and finiteness check:
A missing or extra
1/Twould show up immediately as O(100) or O(0.01).