[Ulysses] Carry the KV head count per DistributedAttention - #8316
Conversation
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.
There was a problem hiding this comment.
Done — the global is gone, not preserved for legacy callers.
_ulysses_num_kv_heads, set_ulysses_num_kv_heads, get_ulysses_num_kv_heads and
_ulysses_meta() no longer exist, and nothing in the tree references them:
$ grep -rn "_ulysses_num_kv_heads\|set_ulysses_num_kv_heads\|get_ulysses_num_kv_heads" --include='*.py' .
(no matches)
Every call site passes the count explicitly, including the 17 FPDT ones — FPDT derives it from
kv_projection_size // hidden_size_per_attention_head and carries it on both custom autograd
contexts, so the gather direction and the backward pass get it too. That also removed the TODO
you left above the global in #8241.
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.
There was a problem hiding this comment.
Added — _resolve_kv_heads now states the assumption where the count is taken off the tensor:
if num_kv_heads is None and not scatter_idx < 2 and input.shape[2] % seq_world_size != 0:
# ... Taking it as the partition basis assumes one KV group per head: under GQA a
# ... would tear a group across ranks. GQA callers pass the count in instead.
num_kv_heads = input.shape[2]The same reasoning is repeated at the DistributedAttention call site, where the count comes off
the key tensor rather than the query tensor for the same reason.
|
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: |
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 |
|
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. |
|
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. |
|
Note on the conflicting badge: it is inherited from #8241, not from this change. The conflicting files are I checked whether this could be rebased onto master independently: it cannot. Cherry-picking my commits onto master conflicts in So this stays parked until #8241 lands and I rebase onto it, as you suggested. Not pinging about that yet — happy to keep waiting. |
|
Hi @alanhuangyoo #8241 is merged now, can you rebase to master and resolve conflicts? Thanks for your efforts! |
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>
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>
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>
8ed59cf to
74ad9c7
Compare
|
Rebased onto master at The three commits replay cleanly onto the merged #8241 — The One thing the rebase surfaced: moving
|
|
@delock this is ready for another look — both of your comments are addressed and the rebase you
CI is green and there are no unresolved threads left on my side. |
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.