Skip to content

Fix Whisper beam_indices so compute_transition_scores does not select the beam twice - #48623

Open
speedsharmaai wants to merge 2 commits into
huggingface:mainfrom
speedsharmaai:fix/whisper-beam-transition-scores
Open

speedsharmaai wants to merge 2 commits into
huggingface:mainfrom
speedsharmaai:fix/whisper-beam-transition-scores

Conversation

@speedsharmaai

@speedsharmaai speedsharmaai commented Sep 8, 2026 •

Copy link
Copy Markdown

CPU CI GPU run-slow

What does this PR do?

Fixes #48621.

compute_transition_scores() raises for Whisper with num_beams > 1:

RuntimeError: index 156073 is out of bounds for dimension 0 with size 51865

The beam axis is selected twice.

_postprocess_outputs already gathers scores from the beam each step was generated on (added in #32336, fixing #32246):

if beam_indices is not None and key == "scores":
    return [v[beam_idx].cpu() for (v, beam_idx) in zip(values, beam_indices[batch_idx][: len(values)])]

So the stacked scores[t] hold one row per returned sequence, not one row per beam. The returned beam_indices still carried the beam ancestry, and compute_transition_scores multiplies it by vocab_size to index into the flattened scores — reaching rows that no longer exist.

It only raises when the winning beam's ancestry passes through a beam other than 0. Sequences that stayed on beam 0 gather 0 * vocab_size + token, which is in range, and return plausible-looking but arbitrary scores. That is what made this look data-dependent.

Fix

Point beam_indices at the rows of the scores that are actually returned, so the two outputs agree. -1 is preserved, so steps past the end of a sequence are still masked by compute_transition_scores.

With this, the usual beam-search invariant holds again — transition_scores.sum(-1) reproduces sequences_scores exactly:

sum              tensor([-40.0329, -40.0329, -40.0329, -40.0325, -40.0325, -40.0325])
sequences_scores tensor([-40.0329, -40.0329, -40.0329, -40.0325, -40.0325, -40.0325])

The remap is guarded on scores being present, so output_scores=False still returns the raw ancestry.

On the alternative

@vasqu noted on the issue that the general rule is to follow what core generate does. The other way to fix this is to stop gathering scores by beam in _postprocess_outputs and return the plain (batch_size * num_beams, vocab_size) rows that core beam search returns, which would make beam_indices correct as-is.

I did not go that way because it reverts the deliberate behaviour from #32336 and changes what outputs.scores means for every Whisper user since v4.45 — that felt like a call for maintainers rather than a drive-by fix. Happy to redo it that way if you prefer the core semantics.

Tests

tests/models/whisper/test_modeling_whisper.py::WhisperModelTest::test_beam_search_transition_scores asserts that each transition score comes from the row of its own sequence, and that the scores sum back to sequences_scores. On main it fails with the reported RuntimeError.

tests/models/whisper/test_modeling_whisper.py passes: 370 passed, 346 skipped.

Who can review?

@eustlb @vasqu (Whisper / generation)

… the beam twice

_postprocess_outputs already gathers scores from the beam each step was
generated on, so the stacked scores hold one row per returned sequence
rather than one row per beam. The returned beam_indices still carried the
beam ancestry, so compute_transition_scores multiplied it by vocab_size
and gathered out of bounds:

    RuntimeError: index 156073 is out of bounds for dimension 0 with size 51865

It only raised when the winning beam's ancestry passed through a beam
other than 0; sequences that stayed on beam 0 gathered in range and
returned plausible but arbitrary scores.

Point beam_indices at the rows of the scores that are actually returned,
keeping -1 for steps past the end of a sequence so they are still masked.
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Thank you for your contribution 🤗!

CI Security Gate — automatic approval blocked

This PR was not automatically approved for CI because the security gate failed.

Possible reasons:

  • The PR touches 50 or more files — only PRs with fewer than 50 changed files are automatically approved
  • A changed file is outside the allowed directories (src/, tests/, docs/, utils/), has a disallowed extension (only .py, .txt, .md permitted outside tests/ and docs/), or is not .md/.yml inside docs/ — this covers files the PR deletes or renames, not only the ones it edits
  • A new high-severity security issue was detected in the changed Python files (Bandit check)
  • The PR touches a path this repository protects from untrusted PRs, such as the file that decides who reviews it — a maintainer must make that change in a separate PR

See the workflow run for the exact violations.

A maintainer can review and manually approve CI if a finding is a false positive.

@speedsharmaai

Copy link
Copy Markdown
Author

For whoever triages the security gate: none of the listed reasons apply here — the PR changes two files, src/transformers/models/whisper/generation_whisper.py and tests/models/whisper/test_modeling_whisper.py.

The get-changed-files job failed on the artifact upload itself:

Failed to FinalizeArtifact: Received non-retryable error: Failed request: (403) Forbidden

so the bandit step never ran.

@vasqu vasqu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some small initial comments from my side. Whisper is unique so this is totally fine imo, the comments are more on style

Comment on lines 1207 to +1216
if key in ["sequences", "beam_indices", "token_timestamps"]:
outputs[key] = torch.stack([v[key] for v in seek_outputs], dim=0).to(device)
if key == "beam_indices" and "scores" in seek_outputs[0]:
# `split_by_batch_index` already gathered `scores` from the beam each step was generated on,
# so the stacked scores hold one row per returned sequence instead of one row per beam. Point
# `beam_indices` at those rows, otherwise `compute_transition_scores` selects the beam a second
# time and gathers out of bounds. `-1` marks steps past the end of a sequence and is kept.
beam_indices = outputs[key]
rows = torch.arange(beam_indices.shape[0], device=device).unsqueeze(1).expand_as(beam_indices)
outputs[key] = torch.where(beam_indices == -1, beam_indices, rows)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very nitpicky but could we split this if then for beam as its own case + maybe reference the issue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feel like we could potentially also shorten the comment, im getting claude vibes (sorry if thats not the case!)

self.assertEqual(output.beam_indices.shape[0], input_features.shape[0] * 3)
self.assertEqual(output.sequences_scores.shape[0], input_features.shape[0] * 3)

def test_beam_search_transition_scores(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would rather like a style of test_model_forward

self.assertEqual(output.sequences_scores.shape[0], input_features.shape[0] * 3)

def test_beam_search_transition_scores(self):
config, input_dict = self.model_tester.prepare_config_and_inputs()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing docstring and reference to the issue/PR

@vasqu

vasqu commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

The bandit is wonky, it might be also due to your branch being not up to date with main. But anyways false flag

@kaokai-ui

Copy link
Copy Markdown

We filed #48621, and this is the hardware where the bad index doesn't raise but hangs the machine, so we ran your patch against it. transformers 5.14.1, openai/whisper-tiny, noise input, default length_penalty.

num_beams=5, num_return_sequences=1 num_beams=5, num_return_sequences=5
unpatched, CPU 6/444 indices out of bounds → RuntimeError: index 156073 is out of bounds for dimension 0 with size 51865 1776/2220 out of bounds → RuntimeError
patched, CPU 0/444, and all 444 transition scores equal scores[step][row, token] 0/2220, all 2220 match
patched, ROCm gfx1103 same, and no hang same, and no hang

Before the patch this path leaves the HIP stream un-drained and the next device-to-host copy spins forever — the process can't be killed and the machine needs a hard power-off (pytorch/pytorch#196377). After it, compute_transition_scores() returns and the 2,220 follow-up D2H reads all complete, 42 s for both cases.

One thing worth checking in the new test: with length_penalty=0.0 on the real checkpoint, generation collapses to 2 steps whose ancestry never leaves beam 0, so the gather is in bounds and the assertions pass on unpatched code as well. Our first verification run hit exactly that and reported a false pass. The tiny random tester model may behave differently, but it would be worth asserting that the case really does exercise beam_indices > 0 — otherwise the test can go green without touching the bug.

Possibly out of scope: _stack_split_outputs() only runs when force_unique_generate_call or not return_timestamps. Multi-segment longform returns each segment's seek_outputs[idx] directly, which still pairs pre-selected scores with the raw ancestry.

A run whose ancestry never leaves beam 0 gathers in bounds even without
the fix, so the assertions would pass on unpatched code. Capture the
ancestry before _stack_split_outputs rewrites it and require at least one
index above 0.
@speedsharmaai

Copy link
Copy Markdown
Author

Thanks for running it on the hardware that hangs — that ROCm detail is worse than what I could see locally.

You're right about the test. It did happen to fail on unpatched code here (the tiny random tester model's ancestry does leave beam 0), but nothing in the test said so, and a model or seed change could have made it vacuous without anyone noticing. Pushed 2dd2996: it now captures the ancestry before _stack_split_outputs rewrites it and asserts at least one index is above 0. Reverting the fix still gives index 1885 is out of bounds for dimension 0 with size 1800, and the whole file is 370 passed / 346 skipped.

On longform: agreed, the multi-segment path returns seek_outputs[idx] directly and still pairs pre-selected scores with the raw ancestry. It needs a different value than the stacked path — those per-segment scores are a list of (vocab,) tensors with no row axis at all, so the consistent index there is 0 rather than the row. @vasqu, do you want that in this PR or separately? Happy either way.

@vasqu on the bandit flag — I left the branch on its original base for now. Rebasing onto main pulls in a workflow file change that my token can't push.

@github-actions

Copy link
Copy Markdown
Contributor

[For maintainers] Suggested jobs to run (before merge)

run-slow: whisper

@github-actions

Copy link
Copy Markdown
Contributor

CI recap

Dashboard: View test results in Grafana
Latest run: 34526459054
Result: success | Grafana metrics are not available yet.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

compute_transition_scores() applies beam selection twice for Whisper → out-of-bounds gather

3 participants