NVIDIA-NeMo/RL #4612 · permalinks pin head 2548c679. The review comment is in a pending review; its link works once the review is published.
Three pages on this PR — this one: the one resume path that has no routes.
A cut can land after vLLM has produced a call's last token but before the call's final row is saved. It can also land when the saved tokens already hit the output limit. On resume, _classify_restored_prefix_terminal sees this and skips decoding. _build_restored_prefix_terminal_output then builds a fake vLLM output with no routed_experts. With router replay on, the reply always goes through the route-attach step, and that step raises when there are no routes. The saved routes were fetched and checked, then never used.
Side effect: the generated_len == 0 case in the new merge, replace_len = restored.shape[0] if generated_len == 0 else …, is written for exactly this call, but the call never gets there. No test covers a finished resume with router replay on. A GPU run with router replay and a forced restart would have shown it.
Nothing below is implemented, and it has not been run.
Where the fix lands: RL only, one file, vllm_worker_async.py. No Gym change: the saved routes are already fetched and checked on resume. They only need to be handed to the fake output.
@@ vllm_worker_async.py L26, L71-L72 · imports -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from nemo_rl.models.generation.vllm.utils import ( + R3_MISSING_ROUTE_SENTINEL, attach_routed_experts_to_chat_response_choices, @@ vllm_worker_async.py L92-L99 · _RestoredPrefixTerminal finish_reason: Literal["stop", "length"] stop_reason: str | int | None = None + # Saved routes for every prompt position, so the route-attach step has rows + # even though no decoding runs. -1 where no route was ever computed. + routed_experts: torch.Tensor | None = None @@ vllm_worker_async.py L182-L190 · _build_restored_prefix_terminal_output finish_reason=terminal.finish_reason, stop_reason=terminal.stop_reason, + routed_experts=terminal.routed_experts, ) @@ vllm_worker_async.py L2035 · preprocess_chat if restored_prefix_terminal is not None: + if generation_cut_routed_experts is not None: + saved = generation_cut_routed_experts + # Rows before prev_len were never routed by this call. + unrouted = torch.full( + (admission.prev_len, *saved.shape[1:]), + R3_MISSING_ROUTE_SENTINEL, + dtype=saved.dtype, + ) + # Drop the saved placeholder row; route-attach adds its own. + restored_prefix_terminal = replace( + restored_prefix_terminal, + routed_experts=torch.cat((unrouted, saved[:-1])), + ) request.min_tokens = 0 @@ vllm_worker_async.py L1459 · _merge_generation_cut_routed_experts - replace_len = restored.shape[0] if generated_len == 0 else restored.shape[0] - 1 + # The saved last row is a placeholder; the row from this call wins. + replace_len = restored.shape[0] - 1
On the example: the fake output now carries [1, 2, 3, 4]. The route-attach step adds a -1 for the last position (same as every normal reply), giving [1, 2, 3, 4, -1]. That is exactly what the call would have saved if no restart had happened. The merge then has no special case left. A simpler fallback is to refuse the cut, so the call regenerates, when routes are on and the call is already finished. That works today only because the last token and its finish signal are recorded under one lock.
🔴 review comment — vllm_worker_async.py:1459