[V1] Copy NaN-in-logits counts to host asynchronously (#51304)

Signed-off-by: Nick Hill <nickhill123@gmail.com>

Nick Hill committed Aug 6, 2026 at 17:05 UTC b1e12d142d8c9533f857f8da13d8fc368e95a8cd
1 file changed +52 -18
vllm/v1/worker/gpu_model_runner.py
+52 -18
@@ -265,6 +265,23 @@ AttnMetadataDict: TypeAlias = dict[str, AttentionMetadata]
265 PerLayerAttnMetadata: TypeAlias = list[AttnMetadataDict] | AttnMetadataDict
266
267
268 +def count_nans_per_row(logits: torch.Tensor) -> torch.Tensor:
269 + """Per-row NaN counts, left on device."""
270 + return logits.isnan().sum(dim=-1, dtype=torch.int32)
271 +
272 +
273 +def nans_to_dict(counts: list[int], req_id_to_index: dict[str, int]) -> dict[str, int]:
274 + """Map per-row NaN counts onto request ids.
275 +
276 + Rows and requests need not correspond one-to-one, so requests without a
277 + row are reported as 0.
278 + """
279 + return {
280 + req_id: int(counts[i]) if i < len(counts) else 0
281 + for req_id, i in req_id_to_index.items()
282 + }
283 +
284 +
285 # Wrapper for ModelRunnerOutput to support overlapped execution.
286 class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
287 def __init__(
@@ -277,6 +294,7 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
294 vocab_size: int,
295 routed_experts: RoutedExpertsTensors | None = None,
296 check_ep_fault: bool = False,
297 + num_nans: torch.Tensor | None = None,
298 ):
299 self._model_runner_output = model_runner_output
300 self._invalid_req_indices = invalid_req_indices
@@ -291,6 +309,7 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
309 self.vocab_size = vocab_size
310 self._logprobs_tensors = logprobs_tensors
311 self._routed_experts = routed_experts
312 + self._num_nans = num_nans
313 self._has_fault: torch.Tensor | None = None
314
315 # Initiate the copy on a separate stream, but do not synchronize it.
@@ -310,6 +329,11 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
329 if self._routed_experts is not None
330 else None
331 )
332 + self._num_nans_cpu = (
333 + self._num_nans.to("cpu", non_blocking=True)
334 + if self._num_nans is not None
335 + else None
336 + )
337 if check_ep_fault:
338 has_fault = get_ep_all2all_manager().query_fault()
339 self._has_fault = has_fault.to("cpu", non_blocking=True)
@@ -349,6 +373,14 @@ class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
373 output.routed_experts = self._routed_experts_cpu.tolists()
374 del self._routed_experts
375
376 + if self._num_nans_cpu is not None:
377 + output.num_nans_in_logits = nans_to_dict(
378 + self._num_nans_cpu.tolist(), output.req_id_to_index
379 + )
380 + if envs.VLLM_RAISE_ON_LOGIT_NANS:
381 + raise_if_nan_logits(output.num_nans_in_logits)
382 + del self._num_nans
383 +
384 if self._has_fault is not None and self._has_fault.item():
385 mask = get_ep_all2all_manager().query_active_mask()
386 raise RuntimeError(
@@ -3737,6 +3769,7 @@ class GPUModelRunner(
3769 num_scheduled_tokens: int,
3770 ) -> tuple[
3771 dict[str, int],
3772 + torch.Tensor | None,
3773 LogprobsLists | None,
3774 list[list[int]],
3775 dict[str, LogprobsTensors | None],
@@ -3744,9 +3777,15 @@ class GPUModelRunner(
3777 dict[str, int],
3778 list[int],
3779 ]:
3747 - num_nans_in_logits = {}
3780 + num_nans: torch.Tensor | None = None
3781 + num_nans_in_logits: dict[str, int] = {}
3782 if envs.VLLM_COMPUTE_NANS_IN_LOGITS:
3749 - num_nans_in_logits = self._get_nans_in_logits(logits)
3783 + if self.use_async_scheduling:
3784 + # Keep the counts on device; they ride the async output copy
3785 + # stream rather than blocking here.
3786 + num_nans = None if logits is None else count_nans_per_row(logits)
3787 + else:
3788 + num_nans_in_logits = self._get_nans_in_logits(logits)
3789
3790 num_reqs = self.input_batch.num_reqs
3791 discard_sampled_tokens_req_indices = np.nonzero(
@@ -3861,6 +3900,7 @@ class GPUModelRunner(
3900
3901 return (
3902 num_nans_in_logits,
3903 + num_nans,
3904 logprobs_lists,
3905 valid_sampled_token_ids,
3906 prompt_logprobs_dict,
@@ -4713,6 +4753,7 @@ class GPUModelRunner(
4753 with record_function_or_nullcontext("gpu_model_runner: bookkeep"):
4754 (
4755 num_nans_in_logits,
4756 + num_nans_device,
4757 logprobs_lists,
4758 valid_sampled_token_ids,
4759 prompt_logprobs_dict,
@@ -4815,6 +4856,7 @@ class GPUModelRunner(
4856 vocab_size=self.input_batch.vocab_size,
4857 routed_experts=routed_experts_snapshot,
4858 check_ep_fault=self.check_ep_fault,
4859 + num_nans=num_nans_device,
4860 )
4861 with record_function_or_nullcontext(
4862 "gpu_model_runner: set_async_sampled_token_ids"
@@ -5718,23 +5760,15 @@ class GPUModelRunner(
5760
5761 return prompt_logprobs_dict
5762
5721 - def _get_nans_in_logits(
5722 - self,
5723 - logits: torch.Tensor | None,
5724 - ) -> dict[str, int]:
5725 - try:
5726 - if logits is None:
5727 - return {req_id: 0 for req_id in self.input_batch.req_ids}
5763 + def _get_nans_in_logits(self, logits: torch.Tensor | None) -> dict[str, int]:
5764 + """Count NaNs per request, reading the result back to the host.
5765
5729 - num_nans_in_logits = {}
5730 - num_nans_for_index = logits.isnan().sum(dim=-1).cpu().numpy()
5731 - for req_id in self.input_batch.req_ids:
5732 - req_index = self.input_batch.req_id_to_index[req_id]
5733 - num_nans_in_logits[req_id] = (
5734 - int(num_nans_for_index[req_index])
5735 - if num_nans_for_index is not None and req_index < logits.shape[0]
5736 - else 0
5737 - )
5766 + Only used under sync scheduling, The async path keeps the counts
5767 + on device instead; see`AsyncGPUModelRunnerOutput`.
5768 + """
5769 + try:
5770 + counts = [] if logits is None else count_nans_per_row(logits).tolist()
5771 + num_nans_in_logits = nans_to_dict(counts, self.input_batch.req_id_to_index)
5772 if envs.VLLM_RAISE_ON_LOGIT_NANS:
5773 raise_if_nan_logits(num_nans_in_logits)
5774 return num_nans_in_logits