[Ulysses] Carry the KV head count per DistributedAttention - #8316
[Ulysses] Carry the KV head count per DistributedAttention#8316alanhuangyoo wants to merge 24 commits into
Conversation
…speedai#8231) tp_shard kept num_kv_heads / num_attention_heads / n_embd / tp_grain_size as process-wide mutable globals set during AutoTP replacement. A second AutoTP model loaded into the same process overwrote them, so the first model's later sharding / gather / checkpoint conversion silently read the wrong values. Move that state onto a frozen AutoTPMeta dataclass computed once from the model config and threaded through every sharding helper and TP layer. Each model now carries its own kv-head / grain state, so multiple AutoTP models (teacher / student, online distillation, RL actor + reference) can coexist in one process. Ulysses sequence parallelism, which repurposed the same global, gets its own private kv-head state so it no longer depends on whichever AutoTP model was loaded last. Signed-off-by: Guokai Ma <guokai.ma@intel.com>
AutoTP (``AutoTPMeta.from_model_config``) and the inference engine (``_get_model_head_count`` / ``_get_model_kv_head_count``) each kept their own attribute-name lists for kv-head and attention-head counts, so the two paths recognized different model families (e.g. chatglm only on the AutoTP side, legacy ``n_head_kv`` / ``kv_n_heads`` only on the inference side) and could even disagree on a plain transformer. Consolidate each count behind one shared list and helper in tp_shard so coverage and probe order live in a single place: - ``_KV_HEAD_ATTRS`` / ``_kv_head_count_from`` for the key/value head count - ``_ATTN_HEAD_ATTRS`` / ``_attention_head_count_from`` for the attention head count Both ``AutoTPMeta.from_model_config`` and the inference engine consume them, so neither count is re-extracted on either side. The union keeps the legacy aliases for older configs/checkpoints, annotated with the transformers version that superseded each (``n_head_kv`` after 4.33, ``kv_n_heads`` superseded at top-level by ``num_key_value_heads`` in 4.40). Signed-off-by: Guokai Ma <guokai.ma@intel.com>
``get_head_shard_sizes`` and ``install_head_sharded_helper`` grew a ``meta`` parameter that no caller ever passed -- it was always ``None``, the ``if meta is not None`` kv-head discovery branch was unreachable, and the ``meta or AutoTPMeta()`` fallback always evaluated to ``AutoTPMeta()``. Drop the parameter and the dead discovery block; the helpers honestly take ``num_heads`` / ``num_kv_heads``, which every caller already supplies. Signed-off-by: Guokai Ma <guokai.ma@intel.com>
The alibi helpers (``get_head_shard_sizes``, ``install_head_sharded_helper``) took ``num_heads`` / ``num_kv_heads`` as scalars, so the inference engine extracted them via ``_get_model_head_count`` / ``_get_model_kv_head_count`` -- a second copy of the head-count probe that ``AutoTPMeta.from_model_config`` already does. With AutoTPMeta carrying both counts, the helpers now take a single ``meta`` and the inference engine builds one per model (via the shared ``_attention_head_count_from`` / ``_kv_head_count_from`` probes), deleting the two ``_get_model_*`` methods. The runtime alibi wrappers and ``_head_shard`` are unchanged: they still consume the ``head_shard_sizes`` + ``total_num_heads`` bound at install time, now derived from meta. Signed-off-by: Guokai Ma <guokai.ma@intel.com>
test_gate_up_partition_ignores_later_grain_size_changes existed to check that a layer's frozen shard widths survived a second AutoTP model overwriting the process-wide grain global. With per-model AutoTPMeta the "second model" leg became vacuous -- a layer holding meta A is unaffected by merely constructing a layer with meta B -- so the test no longer tested what its name says. Fold its one piece of real value (the explicit _subparam_shard_widths == [[3,2],[3,2]] assertion) into test_gate_up_partition_covers_the_whole_weight, which already exercises the same layer and partition. Signed-off-by: Guokai Ma <guokai.ma@intel.com>
_bigcode_type_transpose slices the fused projection at meta.n_embd to separate the query block from the replicated kv block. n_embd is Optional, and Python reads input[:None] as "to the end", so a missing hidden size would hand the whole weight to q and leave kv empty -- a silently wrong shard rather than an error. Assert the value is present before slicing. Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
fused_LinearLayer defaulted a missing tp_meta to an empty AutoTPMeta before resolving its sub-parameter layout. That default is not a neutral choice here: with num_kv_heads left as None, fused_qkv_subparam_sizes skips the chatglm kv-head branch and falls back to splitting the fused weight into three equal blocks, so the layer would partition to a structurally wrong layout instead of failing. Index kwargs directly so an omitted tp_meta raises. Every AutoTP construction site already passes it; only one test relied on the default and now states the empty meta explicitly, where the codegen layout it exercises does not consult it. Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
get_shard_size / get_shard_size_list took a num_kv_heads argument that shadowed meta.num_kv_heads, so the same name meant "this model's kv-head count" in one place and "use this instead" in another. Call it eff_num_kv_heads: the head count the split is actually aligned to, defaulting to the meta value. get_head_shard_sizes was reading meta.num_kv_heads only to pass it straight back as that argument, which is what get_shard_size_list already does when it is omitted. Drop the round trip. Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
The override existed because get_shard_size read a process-wide num_kv_heads that was only set during AutoTP replacement. The inference engine patches the alibi helpers before that happens, so it probed the head count itself and passed it in to bypass the uninitialized global. That was the parameter's only caller. With the count carried by a per-model AutoTPMeta, the alibi path receives its own model's value like everyone else, and the previous commit removed the round trip that read meta.num_kv_heads only to pass it straight back. Nothing outside tp_shard supplies the argument now, so remove it and the two tests written against it; get_shard_size has a single source for the head count again. Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
Giving Ulysses its own kv-head state stops AutoTP from overwriting it, but the memo itself is set once and never reset, so the first model to take the uneven path decides the split for every later call in the process. Note the limitation and what fixing it would cost, since the gather direction cannot recover the total head count from the tensor shape and would need it threaded through _SeqAllToAll and its backward pass. Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
get_shard_size only takes the kv-head-aligned path for attention projections whose dimension divides by the head count; MLP, the last linear and MoE expert layers are excluded so they keep a near-even split. Only the attention case was asserted, so deleting the last_linear or MoE branch left the suite green. Fold the single assertion into a parametrization that pairs each exclusion with the split it produces, and add the non-divisible case. Removing any one of the four rules now fails a specific case rather than none. Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
The isolation this PR provides had no end-to-end coverage. The unit test that claimed it built two frozen AutoTPMeta objects and called a pure function with each, which cannot fail regardless of the implementation, so it is replaced. Inject two models in one process with different kv-head counts and assert the first one's split survives. 3 kv heads over 2 ranks splits 192 unevenly as [128, 64] while 2 heads gives [96, 96], and [96, 96] is exactly what the first model would produce once a shared kv-head count had been overwritten, so the two outcomes are distinguishable. Re-deriving from the first model's meta after the second is built covers the value itself rather than the frozen result. Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
AutoTPMeta.from_model_config probed the config it was handed directly, so a multimodal outer config (head counts only under text_config) lost num_kv_heads / num_attention_heads / hidden_size and the sharding fell back to an even-grain split that can cut through KV heads under GQA. Descend into text_config inside from_model_config so every caller (runtime engine, inference engine, direct AutoTP use) shares the fix. Signed-off-by: Guokai Ma <guokai.ma@intel.com>
Per maintainer feedback the tests-to-preserve tree is tests/unit/v1, which is also the scope the modal GPU workflow selects, so the AutoTP tests run on multi-accelerator CI once they live there. Update the xpu workflow's path accordingly. Signed-off-by: Guokai Ma <guokai.ma@intel.com>
…ayers Signed-off-by: Guokai Ma <guokai.ma@intel.com>
The GPU CI run surfaced a NameError in register_replicated_grad_hooks: print_dist was called without being imported (log_dist was). Add it to the existing deepspeed.utils.logging import. Signed-off-by: Guokai Ma <guokai.ma@intel.com>
Moving them into tests/unit/v1 exposed test_tp_plan_real_models to the modal workflow, which tests against transformers main; that main now injects 'embedding_rowwise' into tp_plan for tied-embedding models (deepspeedai#8290), so every full-suite modal run on master would fail until the upstream drift is handled. Move the directory back for now; it will be relocated into tests/unit/v1 once deepspeedai#8290 is fixed. Signed-off-by: Guokai Ma <guokai.ma@intel.com>
The full model_parallelism directory stays out of tests/unit/v1 until the transformers-main 'embedding_rowwise' drift (deepspeedai#8290) is handled, but the two multi-model regressions this PR adds (deepspeedai#8231 reproduction and the multimodal text_config path) are safe there and belong to the GPU workflow's scope. Signed-off-by: Guokai Ma <guokai.ma@intel.com>
The whole directory is multi-rank DistributedTest (world_size 2/4), so it was never executed in PR CI: cpu runners skip it and the modal GPU workflow's diff-driven selector only covers tests/unit/v1. Moving it under tests/unit/v1 brings it into the modal GPU workflow's scope, so these tests now actually run on multi-accelerator CI. Also update the xpu-max1100 workflow to point at the new location. Signed-off-by: Guokai Ma <guokai.ma@intel.com>
FU-max-boop
left a comment
There was a problem hiding this comment.
Thanks for taking the dependency-free route, and for incorporating the GQA partitioning point. I ran exact head b4f148056b61586fcdc8970f20504ba93f5c075d against base 6e3bd087f9bee031a6853891e35ead44a80122ad with a deterministic 2-rank CPU/Gloo harness. I found two regressions that look blocking before this version lands.
1. Legacy direct _SeqAllToAll.apply loses the inferred odd head count before backward
When the caller omits num_kv_heads, _SeqAllToAll.forward resolves _KV_HEADS_FROM_GLOBAL to the current global value (None) before calling single_all_to_all. The scatter-side helper can then infer num_heads == 3, but it no longer knows the sentinel was used, so the resolved count is stored in neither ctx.num_kv_heads nor the compatibility global. Backward receives explicit None; with scatter/gather swapped, it selects the equal-split path and the ranks disagree on collective sizes.
With a direct odd-head forward/backward (heads=3, SP=2, requires_grad=True):
base:
(1, 'ok', (1, 8, 1, 2), True)
(0, 'ok', (1, 8, 2, 2), True)
('exitcodes', [0, 0])
#8316:
gloo::EnforceNotMet: op.nread == op.preamble.nbytes
(0, 'hung', ...)
('exitcodes', [-15, -6])
A minimal direction would be to preserve whether the argument was unspecified, resolve/infer the effective count once on the scatter side, store that effective value in the autograd context, and replay the same value in backward. A direct _SeqAllToAll.apply odd-head autograd regression would cover the compatibility path.
2. num_kv_heads < world_size now enters a collective with a zero-head rank
For Q=2 / KV=1 / SP=2, an explicit or key-inferred num_kv_heads=1 makes the new num_kv_heads is not None branch bypass the old pre-collective head-count guard. Rank 1 receives local_heads == 0, reaches h_dim = h // local_heads, and raises while rank 0 is already waiting in a collective.
base:
rank 0: AssertionError: Number of heads (1) must be larger than sequence parallel size (2)
rank 1: AssertionError: Number of heads (1) must be larger than sequence parallel size (2)
('exitcodes', [0, 0]) # worker reports the caught error, then exits normally
#8316:
(0, 'hung', ...)
(1, 'err', 'ZeroDivisionError', 'integer division or modulo by zero', ...)
deepspeed/sequence/layer.py:183: h_dim = h // local_heads
('exitcodes', [-15, 0])
This needs a rank-consistent validation before the first collective: reject num_kv_heads < world_size (or define a supported zero-head protocol) on every rank. A Q=2 / KV=1 / SP=2 regression should assert synchronized fail-fast behavior.
Reproducer: https://gist.github.com/FU-max-boop/2da2d1c018af3da35e24f04829da1445
SHA-256: 22c4359ce38b92a76737c378c97b5fab73d2dda22881ca4e79b83963ef383120
Run from either checkout root:
PYTHONPATH="$PWD" DS_ACCELERATOR=cpu python /path/to/repro_8316_p1.py direct-odd --timeout 12
PYTHONPATH="$PWD" DS_ACCELERATOR=cpu python /path/to/repro_8316_p1.py mqa-kv-lt-world --timeout 12The current Modal job is green, but its execution log is 79 passed, 9 skipped from the compile suite; it did not exercise the new Ulysses test file or the existing direct odd-head regression, so it does not cover these cases.
The dependency-free approach is the preferable landing path if these are fixed. I am happy to rerun the harness and review the next head. I will keep my stacked total_heads / partition_heads implementation only as a fallback rather than opening a competing PR while this one is active.
|
Both reproduce here, thanks — the harness made them easy to confirm. Same two cases on a 2-rank gloo harness, against Your reading of the first one is right. The second one is the Both come from resolution being split across two places, so it now happens in one, After that, both cases match master exactly: Added both as regression tests: Pushed. Worth another look when you have time — and the offer stands, if you would rather land your stacked version on top of #8241 I will close this one. |
| type=None, | ||
| is_fwd=True) -> Tensor: | ||
| is_fwd=True, | ||
| num_kv_heads=_KV_HEADS_FROM_GLOBAL) -> Tensor: |
There was a problem hiding this comment.
Instead of preserving the global for legacy case, we need to remove the global and fix all call site. Both FPDT and Megatron-DeepSpeed has this information and can call this function with explicit num_kv_heads.
There was a problem hiding this comment.
I audited the current exact head and the relevant in-tree/external callers against this direction.
The smallest global-free route I see is:
- The only in-tree direct callers of
single_all_to_alloutsidelayer.pyare the 17 FPDT call sites indeepspeed/sequence/fpdt_layer.py. FPDT already carrieskv_projection_sizeandhidden_size_per_attention_head, so its effective KV-head count iskv_projection_size // hidden_size_per_attention_head. That count can be stored on each custom autograd context and threaded explicitly through Q/K/V/output and all reverse-direction calls. The reverse calls cannot reliably infer it from their already-sharded tensor. - At Megatron-DeepSpeed head
aab2f3127c9a5375019221c3a5405ea4cdf98b5e,ParallelAttentionalready computesself.num_key_value_heads_per_partitioninmegatron/model/transformer.py:566-567; this local TP-partition count (rather than the model-global config value) is the count available to pass toDistributedAttention(..., num_kv_heads=...)at lines 619-622. - The Megatron-DeepSpeed FPDT factory also has
config.num_key_value_headswhen constructingFPDT_Attention(lines 922-971), while the in-tree FPDT implementation can derive the same count from its existing projection metadata. - The remaining in-tree
DistributedAttentionconstructors are docs/blog examples, the compile test, and this PR's tests. The implicit direct-call regression attest_ulysses_multiple_models.py:111-122should become an explicit-count backward replay test; the global fixture/imports can then disappear entirely.
If that is the intended contract, I am happy to validate the next DeepSpeed head and/or prepare a small companion Megatron-DeepSpeed caller update after this PR API shape is settled. I will not duplicate the main implementation here.
FU-max-boop
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 1d4420b3cc3e360da57c8463590f445ec81ae9bb.
Both previously reported distributed blockers are fixed in the independent 2-rank CPU/Gloo reproductions: the direct odd-head _SeqAllToAll forward/backward now completes with the expected per-rank shapes and gradients, and Q=2 / KV=1 / SP=2 now fails consistently on both ranks before any collective.
I also exercised a Q-width GQA path (Q=6 / KV=3 / SP=2) whose local attention output depends on q, k, and v. For both batch_dim_idx=0 and 1, the full Q/K/V/O forward/backward round trip produced the exact output and expected finite gradients (dq=1, dk=0.5, dv=1) without a collective mismatch or hang.
The repaired delta introduces no remaining P0/P1 finding in my review. The hosted CPU job is green but skips the 2-rank Ulysses cases, so the statements above are based on the exact-head distributed harness rather than inferred from that hosted result. I support landing the dependency-free implementation.
| num_kv_heads = get_num_kv_heads() | ||
|
|
||
| if num_kv_heads is None and not scatter_idx < 2 and input.shape[2] % seq_world_size != 0: | ||
| num_kv_heads = input.shape[2] |
There was a problem hiding this comment.
here the assumption is non GQA and attention dim is 2. We should put this in comments for better understanding.
|
Hi @alanhuangyoo thanks for your PR. I have left some initial comments. Also this PR needs to be done on top of #8241 ,can you rebase with #8241 ? Thanks! |
|
I replayed the three commits in The textual rebase is fairly small: the only
The in-tree callers also need to move with the stack. FPDT has 17 direct One CI detail is worth addressing in the same rebase: |
deepspeedai#8241 gives Ulysses its own _ulysses_num_kv_heads, memoized on the first uneven all-to-all, and a TODO saying the first model to take that path then decides how every later one is sharded. This removes it. The count is threaded through DistributedAttention and _SeqAllToAll, resolved once in forward and replayed from ctx in backward, which is the part the gather direction cannot recover from an already-sharded tensor. AutoTPMeta is built only where get_shard_size_list needs it. It is the KV count, taken from the key tensor rather than the query tensor: under GQA a query head has to land on the rank holding its KV head, and Q // world_size would split the group. Q=6 / KV=3 over sp=2 partitions [4, 2], not [3, 3]. Only an indivisible count takes the uneven implementation. Keying on "a count was supplied" would route evenly-split models there too, and that path rejects async_op, which is how the overlapped q/k calls run. All in-tree call sites move with it. FPDT threads kv_projection_size // hidden_size_per_attention_head through its 17 direct calls and saves it on both custom autograd contexts. The two TestUlyssesAll2All_odd all-to-alls pass their count as well; the second one runs in the gather direction and used to read it back out of the global. The rank-consistent num_kv_heads >= sp_size check stays, before the first collective, so KV=1 over sp=2 fails on every rank rather than leaving one inside a collective while another divides by zero. Regressions land in test_ulysses.py, which nv-flash-attn and hpu-gaudi2-nightly already run: the even fast path with async_op, the uneven path, GQA Q=6/KV=3 with gradients on q, k and v, the synchronized KV=1 rejection, and a 3 -> 5 -> 3 two-model sequence. Megatron-DeepSpeed passes num_key_value_heads_per_partition at the DistributedAttention construction; that is a separate repo and not in this change. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
1d4420b to
26fefc2
Compare
|
Rebased onto #8241 at @delock on the global: One call site that was not on the list: On the second comment, the shape assumption is written down where the count is inferred: dim 2 being the head dim, and the inference holding only when there is one KV group per head, since a GQA query tensor carries a multiple of the KV count and splitting on that number would tear a group across ranks. @FU-max-boop the fast/uneven point was a real bug in what I had, not a style note. Threading a count into FPDT's 17 calls plus an explicit constructor argument meant Tests are consolidated into Results: Megatron-DeepSpeed passing |
The seq-first layout goes through a different branch of _generate_layout_params and uneven_heads_all2all, and the existing odd-head test already exercises both, so the GQA case should too. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Added One thing I could not close, and it is your call @delock. Consolidating into Your own #8241 commit says the same thing about Everything reported above was run directly rather than inferred from CI: 4x H20 world_size 4 for |
|
Hi @alanhuangyoo I think you need to move sequence parallel test as well because through CPU we can't get number of device > 1. I'll check this PR after #8241 merged, thanks for your patience. Ping me if it takes too long. |
These are multi-rank DistributedTest, so the CPU runners skip them and the modal GPU workflow's selector only reaches tests/unit/v1. The new kv-head regressions would have sat outside PR CI for the same reason deepspeedai#8241 moved tests/unit/model_parallelism. nv-flash-attn.yml and the docs link in engine.py follow the path. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Moved, thanks — you are right that CPU cannot give the multi-rank case.
No rush on the review — ping me if anything needs changing once #8241 lands. |
Fixes #8291.
What breaks
single_all_to_alldecides whether a model does uneven-head sequence parallelism by asking whether the process-widetp_shardhead count is set:AutoTP writes that same slot on every injection (
replace_module.py:306,engine.py:770), so it is not only a second Ulysses model that clobbers the first — a single AutoTP model anywhere in the process is enough.uneven_heads_all2allthen splits against that value, andget_shard_size_listfalls back to the same global internally, so threading a value without also passingnum_kv_heads=explicitly would not have been enough.A 12-head model on sp=4 should hand every rank 3 heads. With a leftover
num_kv_heads=3:Driving it through
DistributedAttentionon 4 gloo ranks, upstream6e3bd087f:Same script with this branch applied, both cases:
The change
DistributedAttentionholds the count, either from a newnum_kv_headsargument or read off its own key tensor, and only when it does not divide evenly — claiming one otherwise would push every model onto the uneven kernels. It threads the value through_SeqAllToAllintosingle_all_to_allanduneven_heads_all2all, andbackwardrestores it fromctx, which is the piece the gather direction cannot recover from tensor shapes.The value is the KV head count, taken from the key tensor rather than the query tensor. That is what the
tp_shardslot held, and it is what the partition has to follow: under GQA a query head must land on the rank holding its KV head. Q=6 / KV=3 over sp=2 divides evenly on the query side, so reading the query would give [3, 3] and split group 1 across two ranks; the KV side gives [2, 1] and therefore [4, 2]. Thanks to @FU-max-boop for catching that on the issue - my first revision had exactly this bug.get_shard_size_listis called withnum_kv_headsset explicitly so its module-level default cannot reintroduce the leak.Callers that reach
single_all_to_alldirectly, Megatron-DeepSpeed among them, pass nothing and keep the old behaviour including theset_num_kv_headspublish on first use. A sentinel separates "not supplied" from a deliberateNone, which now means "splits evenly, take the fast path".This is independent of #8241 — it touches only
deepspeed/sequence/layer.pyand does not depend onAutoTPMeta— but the two overlap in intent, and I am happy to rebase on it if that lands first.Tests
New
tests/unit/sequence_parallelism/test_ulysses_multiple_models.py, shaped after the multi-model AutoTP test in #8241:DistributedAttentioninstances at 3 and 5 heads over sp=2 — the first still splits[2, 1]after the second has run forward and backwardnum_kv_heads=3— this is the case above, and it hangs on masternum_kv_headsargumentRun on CPU, so
cpu-torch-latestpicks them up rather than only the GPU workflows.