[Feature] Relax the divisibility constraint in P2pNcclAFDConnector - #309
Conversation
|
cc @specture724 |
|
Thanks for sharing the implementation and measurements. I am not sure eager-mode AFD performance with P2pNcclAFDConnector is particularly meaningful for its intended use case: the decode stage of a prefill/decode-disaggregated deployment, with CUDA graphs in FULL_DECODE_ONLY mode. So far, we have not identified a concrete use case for A < F. Our typical deployments use A > F to improve FFN compute utilization, which is why we have not implemented A < F support. Without a concrete application scenario and performance evidence for it under the intended execution mode, we would be reluctant to merge the additional software complexity and maintenance burden. If you have a use case that benefits from A < F, along with corresponding performance results, please share them—we would be happy to revisit this with that evidence. |
|
Thank you for the thoughtful review. The changes in this PR are of three different kinds, so we have laid them out one by one below. On the measurementsTo clarify the measurement setup, since the PR description may not have made this clear: we measured in both eager and graph mode (
The three changes in this PR1. Allowing We fully agree with your point about the intended regime: since AFD batches tokens from several attention ranks to fill the FFN, We are a research group working on optimizing LLM serving systems on heterogeneous GPU clusters. We expected that if attention is placed on GPUs with high memory bandwidth and capacity, and the FFN on GPUs with better compute efficiency, a layout with fewer attention ranks than FFN ranks could arise. Our reference point was MegaScale-Infer, which introduced attention-FFN disaggregation:
That said, we recognize this is a special case specific to our research setting, not a requirement for typical deployments. 2. Relaxing This one is separate from
3. Multi-node deployment Currently
Two questionsOur understanding is that your concern centers on item 1. Most of this PR's complexity is also in item 1 (the slice ops, the dummy batch for a rank that receives zero tokens, and their handling under So we would like to ask:
We would appreciate your guidance on this, and will gladly reshape the PR whichever way you prefer. Thank you again for your time. |
|
Thanks for the clarification. I agree with item 2—relaxing the divisibility constraint while keeping A >= F is something we can support. For item 3, could you open a separate bug issue with a reproducer and the deployment details? We would like to reproduce and investigate it on our side as well. Regarding the eager versus FULL_DECODE_ONLY + DBO measurements, could you verify that FULL_DECODE_ONLY is actually hitting CUDA graph replay? The reported numbers make me suspect that graph replay may not be taking place and execution may be falling back to eager mode. Please check the actual execution path to confirm. |
|
Thank you for going through this carefully, and in particular for the question on graph replay, which pointed at a gap in how we measured. Would the following be acceptable for the three items? Item 2 (relaxing the divisibility constraint): we would narrow this PR to that change alone. The Item 3 (multi-node rendezvous): we will open a separate bug issue with the deployment details and a reproduction that varies only whether Attention and FFN are co-located on one host. If you confirm it and consider it worth addressing, please indicate the approach you would prefer and we will submit it as a separate PR. Item 1 ( Please let us know if you would prefer a different arrangement, and we will proceed accordingly. |
LGTM |
|
Thank @swjeong9 for the work.
We prefer a separate issue & PR for this. And for the design, subgroups only need the stateless process group to make sure every rank in the group get the ncclUniqueId of rank0. And now building stateless process group makes multi-node run fail. I'd like to re-design the subgroup logic: # afd_process_group.py: expose the rendezvous_store
def afd_rendezvous_store(
init_method: str, rank: int, world_size: int, timeout: timedelta
) -> Store:
store, _, _ = next(rendezvous(init_method, rank, world_size, timeout=timeout))
store.set_timeout(timeout)
return store
#p2p.py: replace _gather_rank_addresses + StatelessProcessGroup.create
subgroup_store = PrefixStore(
f"afd_subgroup_{self.mapping.subgroup_index}", root_store
)
self.a2e_group = StatelessProcessGroup(
rank=self.mapping.rank_in_subgroup,
world_size=len(self.mapping.subgroup_ranks),
store=subgroup_store,
) |
|
Item 1 & 2 LGTM |
b2b5053 to
7478ee7
Compare
|
As we discussed, this PR is now narrowed to the change that removes the We took another look at the graph side, as you suggested. We confirmed with DEBUG logs that replay does take place, then turned DEBUG off, measured again, and updated the PR body with those numbers. We will open a separate issue for the multi-node rendezvous. We have also read through the design @specture724 outlined — thank you for sharing it. Should we end up taking that work, we would be glad to follow that direction. Thank you both for the review. If any part of this would be better shaped differently, or if there are further measurements you would like to see, please let us know and we will gladly follow up. |
Thank @swjeong9. We are glad to accept your multi-node work. If you are testing on RDMA NICs, please make sure that GPUs and NICs are binded in the same NUMA node. I remembered that we encountered transfer hang due to GPU Direct RDMA. We just found out how to solve the hang, while didn'd dig into the problem. Just FYI if you encountered the same hang. |
|
Thank you, @specture724 — we are glad to take this on. For the multi-node part, we would like to define the reproduction conditions precisely before opening the issue, and will proceed from there. Thank you also for raising the GPU Direct RDMA hang. Our initial reproduction needed one rank per node, so we scaled out with the smallest single-GPU instances to keep the cost reasonable; EFA is not offered at that size, so the transport was TCP over the standard ENA interface throughout. For the follow-up we would like to extend the matrix to an EFA-enabled configuration, and we will make sure the GPUs and NICs share a NUMA node as you described. We appreciate you raising it ahead of time. |
7478ee7 to
cebd33d
Compare
|
Thank you for reviewing this PR and running CI. I traced the Buildkite unit-test failures to the NPU dependency issue reported in #322. Since this was fixed by #323, I’ve rebased this PR onto the latest The previous pre-commit run also reported 13 mypy errors in Would you prefer these existing typing issues to be addressed in this PR, or handled separately to keep this change focused? |
| for attention_rank in range(attention_size) | ||
| if attention_rank * ffn_size // attention_size == subgroup_index | ||
| ] | ||
| subgroup_ranks = tuple([subgroup_index] + subgroup_attention_ranks) |
There was a problem hiding this comment.
IMHO, is this rank division logic the same as ffn_metadata.py: L42-53? If so, make it a helper:
def subgroup_attention_block(subgroup_index: int, attention_size: int, ffn_size: int) -> range:
"""Attention role ranks owned by one FFN rank: contiguous, sizes within one."""
return range(
(subgroup_index * attention_size + ffn_size - 1) // ffn_size,
((subgroup_index + 1) * attention_size + ffn_size - 1) // ffn_size,
)There was a problem hiding this comment.
Thank you for the review. I agree that consolidating the two expressions into a shared helper makes the code clearer. Applied as suggested.
| ffn_rank * group_size, | ||
| (ffn_rank + 1) * group_size, | ||
| (ffn_rank * attention_size + ffn_size - 1) // ffn_size, | ||
| ((ffn_rank + 1) * attention_size + ffn_size - 1) // ffn_size, |
There was a problem hiding this comment.
Applied the same way.
| ] | ||
| subgroup_ranks = tuple([subgroup_index] + subgroup_attention_ranks) | ||
| rank_in_subgroup = subgroup_ranks.index(world_rank) | ||
| ratio = len(subgroup_attention_ranks) |
There was a problem hiding this comment.
Will ratio == len(subgroup) - 1 always true? If so, remove this attribute and the doc strings in L23-25
There was a problem hiding this comment.
Yes, that always holds. As suggested, the ratio field on AFDRankMapping and its docstring sentence have been removed. Since the attribute no longer exists, tests/unit/distributed/test_topology_snapshot.py now fills ratio in from subgroup_ranks before comparing against the golden entries, so the captured snapshots stay unchanged.
| ] | ||
| subgroup_ranks = tuple([subgroup_index] + subgroup_attention_ranks) | ||
| rank_in_subgroup = subgroup_ranks.index(world_rank) | ||
| ratio = len(subgroup_attention_ranks) |
There was a problem hiding this comment.
Will ratio == len(subgroup) - 1 always true? If so, remove this attribute and the doc strings in L23-25
There was a problem hiding this comment.
Addressed as above.
There was a problem hiding this comment.
FYI: I reviewed the tests with the help of AI, and here's the results.
Review of the test changes only. The production change itself looks right to me; I verified the partition separately and will keep that in a separate comment.
First, credit where it is due: I checked that the golden snapshots are genuine. I copied tests/unit/distributed/test_topology_snapshot.py onto upstream/main and ran it there, and all 23 cases pass. So the "byte-for-byte unchanged for existing topologies" claim holds, and the Captured from main @ 603c111 note is accurate. That is exactly the evidence a reviewer needs.
My concern is that the evidence and the permanent test suite are not the same thing. The three new files add roughly 440 lines, and a large part of that is the same guarantee expressed three times.
1. The golden dict duplicates the invariants test in the same file
test_topology_snapshot.py L42-248 is a 176-line literal dict plus the 6 lines that consume it, covering 3 topologies and 10 mappings. test_rank_mapping_invariants at L251-294 in the same file already asserts every one of those fields, across 11 topologies rather than 3. Each golden entry also echoes back role, role_rank, attention_size and ffn_size, which are the inputs.
The one thing the golden adds over the invariants test is that dataclasses.asdict fails if a new field appears on AFDRankMapping. That does not seem worth 180 lines, and a renamed or removed field already breaks the invariants test by name.
2. test_topology_snapshot.py as a whole duplicates test_topology_partition.py
The file's stated purpose is to show that relaxing the divisibility rule leaves the old layouts unchanged. test_topology_partition.py::test_divisible_layouts_keep_the_historical_grouping does that in 8 lines, over every divisible A, F pair up to 8, by asserting the historical formula directly:
assert mapping.subgroup_ranks == (
ffn_rank,
*(ffn + ffn_rank * ratio + offset for offset in range(ratio)),
)_LEGAL_GRID (11 hand-written divisible pairs) is a subset of _GRID. The roster-agreement and partition-coverage assertions in test_rank_mapping_invariants are the same checks as test_every_attention_rank_belongs_to_exactly_one_subgroup and test_both_roles_agree_on_the_subgroup_they_share.
What is genuinely unique to the invariants test is four assertions, and all four hold for non-divisible layouts too, so they can move into the partition file and run over the wider grid:
assert mapping.world_rank == (role_rank if role == "ffn" else ffn + role_rank)
assert mapping.p2p_rank == (role_rank if role == "ffn" else role_rank + min_size)
assert mapping.min_size == min_size
assert sorted(dp_destination_union) == list(range(ffn))Suggestion: keep the golden file out of the merge and carry those four assertions into test_topology_partition.py. That leaves one new topology test file instead of two.
If you would rather keep a snapshot permanently, a flat table keyed by (A, F, role, role_rank) holding only the six derived fields would be about 10 lines of data instead of 176.
3. Two tests in test_topology_partition.py are already covered
- L37-39
test_validation_accepts_counts_that_do_not_divideonly asserts that no exception is raised. Every other test in the file goes throughbuild_rank_mapping, which callsvalidate_p2p_topologyfirst, and_GRIDcontains the non-divisible pairs. It cannot fail unless a test elsewhere in the file already fails. - L41-45
test_validation_still_rejects_fewer_attention_than_ffn_ranksrepeatstest_p2p_connector.py::test_p2p_topology_validation_errors_are_clear, which pins the same message. The trailing comment intest_topology_snapshot.pysays as much.
4. test_p2p_connector.py topology rows
L173-177 adds four parametrize rows for 3A2F and 5A3F. Now that a dedicated partition file exhaustively covers every A, F up to 8 and pins the literal rosters, these rows do not add coverage at the connector level.
The two rows added to test_p2p_ffn_metadata_tracks_each_attention_peer_in_xayf are a different matter and should stay. They exercise the actual behaviour change in p2p.py, where the peer is now read from subgroup_ranks instead of computed from a uniform ratio.
One coupling to note: the new ratio parametrize column at L165 and the assertion at L199 exist because ratio is no longer A // F. ratio is now exactly len(subgroup_ranks) - 1, and the connector already carries that as group_size. If you decide to drop the field, this column goes away with it.
5. test_ffn_metadata.py
No notes. aggregate_ffn_token_counts had no direct coverage before, and 43 lines for six cases including the empty-counts and TP-expansion edges is proportionate.
Summary
Roughly 290 lines of the new test code are restatements of guarantees the remaining tests already make. Concretely: drop test_topology_snapshot.py after review, move its four unique assertions into test_topology_partition.py, drop the two validation tests there, and drop the four topology rows from the connector test.
None of this is a correctness objection. The tests pass and they test true things.
|
Thank you for the detailed review, especially for checking the snapshots against upstream. I agree that the tests overlapped more than necessary, and your distinction between review-time evidence and the permanent test suite is helpful. I have revised the tests along the lines you suggested. The snapshot file has been removed, and its four unique checks have been moved into I appreciate the time you have taken to review this. |
|
Thank you for the work, LGTM |
|
Please fix the pre-commit issue |
|
The remaining pre-commit errors are addressed by the fix in #328. I’ll follow up here once the outcome of that PR is clear, either by rebasing onto main if it is merged or by applying the fix separately. |
Assign Attention rank `a` to the subgroup of FFN rank `a * F // A` instead of `a // (A // F)`, so `num_attention_ranks` need not be a multiple of `num_ffn_ranks`. `A >= F` is unchanged. Signed-off-by: swjeong9 <swjeong25@gmail.com>
…o field Signed-off-by: swjeong9 <swjeong25@gmail.com>
…he partition tests Signed-off-by: swjeong9 <swjeong25@gmail.com>
32d5ee2 to
0bf8248
Compare
|
Rebased onto the latest upstream main, which resolves the pre-commit failures. All checks pass locally. |
Purpose
This relaxes the
A % F == 0constraint inP2pNcclAFDConnector. The subgroup partition changes froma // (A // F)toa * F // A.Scope
In scope
distributed/topology.py: drop the divisibility check, replace the subgroup partition, readratiofrom the subgroup rosterv1/worker/ffn_metadata.py: aggregate per-FFN token counts with the same partitionconnectors/gpu/p2p.py: look up the control-plane peer in the subgroup roster instead of computing it from a uniform ratioOut of scope
A < FsupportTest Plan
test_topology_snapshot.pypins the rank mapping of the existing topologies, andtest_topology_partition.pyruns every combination ofAandFfrom 1 to 8 to check the properties of the partition.test_ffn_metadata.pyandtest_p2p_connector.pygained3A2Fcases.The GPU runs are on AWS EKS with two g6.12xlarge nodes (4x L4 24GB each), Attention workers on one node and FFN workers on the other. Topologies are
2A2F,3A2Fand4A2F.Test Result
Times are in ms, throughput in tok/s.
2A2Fmain2A2F2A2Fmain2A2F4A2Fmain4A2F4A2Fmain4A2F3A2Fmain3A2F3A2Fmain3A2F3A2Fonmain:For the graph replay check you asked for, we re-ran
2A2Fand3A2FwithVLLM_LOGGING_LEVEL=DEBUG.On both topologies all 776 decode steps on Attention rank 0 report
cudagraph_mode: FULL, with noNONE. On3A2Fthe batch lands on 16, 64 and 80, and all three are served by a captured graph.Docs Impact
README.md,docs/gpu/NCCL_P2P_CONNECTOR_USER_GUIDE.mdanddocs/design/module/connector_contracts.mddrop the divisibility rule and gain a3A2Frow in the example table.Essential PR Checklist