@@ -666,11 +666,13 @@ class ElasticBuffer {
666666 std::vector<int >,
667667 torch::Tensor, torch::Tensor, torch::Tensor, torch::Tensor,
668668 std::optional<torch::Tensor>, std::optional<torch::Tensor>,
669+ std::optional<torch::Tensor>, // recv_aux_weights: per-row router-weight gradient scalar in NvS layout
669670 std::optional<EventHandle>>
670671 dispatch (const torch::Tensor& x,
671672 const std::optional<torch::Tensor>& sf,
672673 const torch::Tensor& topk_idx,
673674 const std::optional<torch::Tensor>& topk_weights,
675+ const std::optional<torch::Tensor>& aux_weights, // second per-(t,k) scalar carried alongside topk_weights
674676 const std::optional<torch::Tensor>& cumulative_local_expert_recv_stats,
675677 const std::optional<int >& cached_num_recv_tokens,
676678 const std::optional<std::vector<int >>& cached_num_recv_tokens_per_expert_list,
@@ -679,6 +681,7 @@ class ElasticBuffer {
679681 const std::optional<torch::Tensor>& cached_dst_buffer_slot_idx,
680682 const std::optional<torch::Tensor>& cached_token_metadata_at_forward,
681683 const std::optional<torch::Tensor>& cached_channel_linked_list,
684+ const std::optional<torch::Tensor>& cached_recv_src_metadata, // cached+expand replay reuses the recorded expanded-row map
682685 const int & num_max_tokens_per_rank,
683686 const int & num_experts, const int & expert_alignment,
684687 const int & num_sms, const int & num_qps,
@@ -687,7 +690,9 @@ class ElasticBuffer {
687690 const bool & async_with_compute_stream,
688691 const bool & allocate_on_comm_stream,
689692 const bool & do_handle_copy, const bool & do_cpu_sync, const bool & do_expand,
690- const bool & use_tma_aligned_col_major_sf) const {
693+ const bool & use_tma_aligned_col_major_sf,
694+ const std::optional<torch::Tensor>& scatter_to_nvs_out,
695+ const std::optional<torch::Tensor>& scatter_src_metadata) const {
691696 // Check SM count
692697 EP_HOST_ASSERT (num_sms > 0 );
693698
@@ -746,6 +751,18 @@ class ElasticBuffer {
746751 topk_weights_ptr = topk_weights->data_ptr <float >();
747752 }
748753
754+ // Optional second per-(t,k) scalar (router-weight gradient), carried alongside topk_weights so a
755+ // single dispatch yields both routing (recv_topk_weights) and this scalar (recv_aux_weights) in the
756+ // same layout, letting combine output d_hidden and d_route_weights in one pass.
757+ float * aux_weights_ptr = nullptr ;
758+ if (aux_weights.has_value ()) {
759+ const auto [num_tokens_a, num_topk_a] = get_shape<2 >(aux_weights.value ());
760+ EP_HOST_ASSERT (num_tokens == num_tokens_a and num_topk == num_topk_a);
761+ EP_HOST_ASSERT (aux_weights->is_cuda () and aux_weights->is_contiguous ());
762+ EP_HOST_ASSERT (aux_weights->scalar_type () == torch::kFloat );
763+ aux_weights_ptr = aux_weights->data_ptr <float >();
764+ }
765+
749766 // Expert receiving counter
750767 int * cumulative_local_expert_recv_stats_ptr = nullptr ;
751768 if (cumulative_local_expert_recv_stats.has_value ()) {
@@ -947,6 +964,7 @@ class ElasticBuffer {
947964 EP_HOST_ASSERT (num_sms <= jit::device_runtime->get_num_sms ());
948965 launch_dispatch (x.data_ptr (), sf_ptr,
949966 topk_idx.data_ptr <topk_idx_t >(), topk_weights_ptr,
967+ aux_weights_ptr,
950968 copied_topk_idx_ptr,
951969 cumulative_local_expert_recv_stats_ptr,
952970 psum_num_recv_tokens_per_scaleup_rank.data_ptr <int >(),
@@ -977,12 +995,27 @@ class ElasticBuffer {
977995 // Assign these values according to modes
978996 if (cached_mode) {
979997 // Cached mode
980- // TODO: support to expand for MoE training backward with cached handles from non-expanding forward,
981- // which requires maintaining the same expanding order between forward and backward
982- EP_HOST_ASSERT (not do_expand and " Cannot do expand with cached mode" );
998+ // Support expand replay for MoE training backward. The fresh expand dispatch that built this
999+ // handle recorded the per-(token,k) expanded-row assignment into recv_src_metadata[:, 2+k]; the
1000+ // cached epilogue reuses that map (passed in via `cached_recv_src_metadata`) instead of
1001+ // re-running the race-dependent atomicAdd, so the replay's expanded layout is row-for-row
1002+ // identical to the forward (see dispatch_copy_epilogue.cuh).
1003+ if (do_expand) {
1004+ EP_HOST_ASSERT (cached_recv_src_metadata.has_value () and
1005+ " cached+expand replay requires cached recv_src_metadata" );
1006+ EP_HOST_ASSERT (expert_alignment == 1 and
1007+ " cached+expand replay is implemented for expert_alignment == 1" );
1008+ }
9831009 EP_HOST_ASSERT (not do_cpu_sync and " Cannot do CPU sync with cached mode" );
9841010 num_recv_tokens = cached_num_recv_tokens.value ();
9851011 num_recv_tokens_per_expert_list = cached_num_recv_tokens_per_expert_list.value ();
1012+ if (do_expand) {
1013+ // Total expanded rows = sum of per-expert received counts (expert_alignment == 1,
1014+ // so there is no per-expert alignment padding). Matches the fresh do_cpu_sync path.
1015+ num_expanded_tokens = 0 ;
1016+ for (const auto c : num_recv_tokens_per_expert_list)
1017+ num_expanded_tokens += c;
1018+ }
9861019 } else if (do_cpu_sync) {
9871020 // Non-cached mode with sync
9881021 const auto start_cpu_time = std::chrono::high_resolution_clock::now ();
@@ -1046,14 +1079,20 @@ class ElasticBuffer {
10461079 auto recv_sf = std::optional<torch::Tensor>();
10471080 auto recv_topk_idx = std::optional<torch::Tensor>();
10481081 auto recv_topk_weights = std::optional<torch::Tensor>();
1049- auto recv_src_metadata = torch::empty (
1050- {num_recv_tokens, num_topk + 2 },
1051- torch::TensorOptions (torch::kCUDA ).dtype (torch::kInt ));
1082+ auto recv_aux_weights = std::optional<torch::Tensor>(); // per-row router-weight gradient scalar output
1083+ // In cached+expand replay, reuse the handle's recorded metadata (its [:, 2+k] columns hold the
1084+ // forward's expanded-row map, which the epilogue reads back). Otherwise allocate fresh.
1085+ auto recv_src_metadata = (cached_mode and do_expand)
1086+ ? cached_recv_src_metadata.value ()
1087+ : torch::empty (
1088+ {num_recv_tokens, num_topk + 2 },
1089+ torch::TensorOptions (torch::kCUDA ).dtype (torch::kInt ));
10521090
10531091 // Optional tensors
10541092 void * recv_sf_ptr = nullptr ;
10551093 topk_idx_t * recv_topk_idx_ptr = nullptr ;
10561094 float * recv_topk_weights_ptr = nullptr ;
1095+ float * recv_aux_weights_ptr = nullptr ; // per-row router-weight gradient scalar
10571096 int recv_sf_token_stride = 0 , recv_sf_hidden_stride = 0 ;
10581097 if (sf.has_value ()) {
10591098 if (not use_tma_aligned_col_major_sf) {
@@ -1077,25 +1116,46 @@ class ElasticBuffer {
10771116 torch::empty ({num_allocated_tokens, num_topk}, topk_weights->options ());
10781117 recv_topk_weights_ptr = recv_topk_weights->data_ptr <float >();
10791118 }
1119+ // recv_aux_weights only in expand mode (needs the per-row [NvS] scalar)
1120+ if (aux_weights.has_value () and do_expand) {
1121+ recv_aux_weights = torch::empty ({num_allocated_tokens}, aux_weights->options ());
1122+ recv_aux_weights_ptr = recv_aux_weights->data_ptr <float >();
1123+ }
10801124
10811125 // Process prefix sum, in expanding mode, it is also atomic counters
10821126 if (do_expand) {
1083- // Slice and exclusive part and do atomic additions into inclusive
1084- EP_HOST_ASSERT (not cached_mode);
1085- psum_num_recv_tokens_per_expert = psum_num_recv_tokens_per_expert.slice (0 , 0 , num_local_experts);
1127+ // For cached+expand the epilogue reuses the recorded expanded-row map (no atomic counters),
1128+ // and the cached psum is already shaped [num_local_experts]. Only the fresh expand path needs
1129+ // the exclusive prefix-sum slice used as atomic counters.
1130+ if (not cached_mode) {
1131+ // Slice the exclusive part and do atomic additions into inclusive
1132+ psum_num_recv_tokens_per_expert = psum_num_recv_tokens_per_expert.slice (0 , 0 , num_local_experts);
1133+ }
10861134 } else if (not cached_mode) {
10871135 // Slice the inclusive part (and will not be used in the epilogue)
10881136 psum_num_recv_tokens_per_expert = psum_num_recv_tokens_per_expert.slice (0 , 1 , num_local_experts + 1 );
10891137 }
10901138 EP_HOST_ASSERT (psum_num_recv_tokens_per_expert.size (0 ) == num_local_experts);
10911139
1140+ // Fused combine-backward scatter. When `scatter_to_nvs_out` is given (only meaningful for the
1141+ // non-expand cached backward), the copy epilogue additionally scatters each received row to
1142+ // out_nvs[scatter_src_metadata[i, 2+k]] -- the NvS destinations recorded by the forward expand
1143+ // dispatch -- fusing the Python-side gather/index_copy into this dispatch kernel. `scatter_src_metadata`
1144+ // must be the forward handle's recv_src_metadata ([num_recv, num_topk+2]).
1145+ const bool do_scatter_to_nvs = scatter_to_nvs_out.has_value () and not do_expand;
1146+ void * out_nvs_ptr = scatter_to_nvs_out.has_value () ? scatter_to_nvs_out->data_ptr () : nullptr ;
1147+ const int * scatter_src_metadata_ptr =
1148+ scatter_src_metadata.has_value () ? scatter_src_metadata->data_ptr <int >() : nullptr ;
1149+ EP_HOST_ASSERT (not do_scatter_to_nvs or scatter_src_metadata_ptr != nullptr );
1150+
10921151 // Launch copy kernels with full SMs
10931152 stream_control_before_epilogue (previous_event_before_epilogue);
10941153 launch_dispatch_copy_epilogue (buffer, workspace,
10951154 psum_num_recv_tokens_per_scaleup_rank.data_ptr <int >(),
10961155 psum_num_recv_tokens_per_expert.data_ptr <int >(),
10971156 recv_x.data_ptr (), recv_sf_ptr,
10981157 recv_topk_idx_ptr, recv_topk_weights_ptr,
1158+ recv_aux_weights_ptr,
10991159 recv_src_metadata.data_ptr <int >(),
11001160 channel_linked_list_ptr,
11011161 num_recv_tokens, num_max_tokens_per_rank,
@@ -1108,6 +1168,7 @@ class ElasticBuffer {
11081168 jit::device_runtime->get_num_smem_bytes (),
11091169 num_channels,
11101170 do_expand, cached_mode,
1171+ do_scatter_to_nvs, scatter_src_metadata_ptr, out_nvs_ptr,
11111172 comm_stream);
11121173
11131174 // Stream control
@@ -1136,6 +1197,7 @@ class ElasticBuffer {
11361197 dst_buffer_slot_idx,
11371198 token_metadata_at_forward,
11381199 channel_linked_list,
1200+ recv_aux_weights, // per-row router-weight gradient scalar in NvS
11391201 event};
11401202 }
11411203
@@ -1186,8 +1248,22 @@ class ElasticBuffer {
11861248
11871249 // Check optional tensors
11881250 if (use_expanded_layout) {
1189- // Reduction should be done with SwiGLU
1190- EP_HOST_ASSERT (not topk_weights.has_value ());
1251+ // Optionally carry the per-expanded-row router weight gradient (droute_weights_nvs) so combine
1252+ // can reduce it back to droute_weights_sk [S,K] in one pass. The weight is a 1D [NvS] float
1253+ // tensor aligned row-for-row with `x`. Each k-slot's scalar must land in its own output column,
1254+ // so we require expanded-send (allow_multiple_reduction == false) — local reduction would merge
1255+ // distinct k-slots' weights — and a single NVLink domain (the only path wired to deliver
1256+ // per-row weights to the source rank).
1257+ if (topk_weights.has_value ()) {
1258+ const auto [num_w] = get_shape<1 >(topk_weights.value ());
1259+ EP_HOST_ASSERT (num_w == num_tokens);
1260+ EP_HOST_ASSERT (topk_weights->is_cuda () and topk_weights->is_contiguous ());
1261+ EP_HOST_ASSERT (topk_weights->scalar_type () == torch::kFloat );
1262+ EP_HOST_ASSERT (not allow_multiple_reduction and
1263+ " expand-combine topk_weights requires allow_multiple_reduction=False" );
1264+ EP_HOST_ASSERT (nccl_context->num_scaleout_ranks == 1 and
1265+ " expand-combine topk_weights is implemented for a single NVLink domain only" );
1266+ }
11911267 } else if (topk_weights.has_value ()) {
11921268 const auto [num_tokens__, num_topk__] = get_shape<2 >(topk_weights.value ());
11931269 EP_HOST_ASSERT (num_tokens == num_tokens__ and num_topk == num_topk__);
0 commit comments