fix(bridge): accept attention_mask in generate() for pre-padded prompts - #1617
Conversation
generate() had no way to be told which prompt tokens are padding, so an already-padded token tensor generated as though its pads were real context: every real token's position was shifted and the continuation diverged from the same prompt unpadded. An attention_mask passed by a hopeful caller was absorbed into **multimodal_kwargs, which are merged into the forward kwargs on step 0 only -- so the first token came out right and every later one drifted, which is harder to spot than a uniform failure. attention_mask is now an explicit parameter. It is extended by one attended column per generated token, so every step sees a mask spanning the prompt plus what has been generated, and forward() derives positions from it. The cached step no longer pins position_ids to total_len - 1, which counts pad slots and is wrong for a left-padded prompt; it reads the new token's position off the mask. padding_side is now applied to token input as well. generate() has always documented the argument but only used it when tokenizing string or list input, leaving it inert for a tensor. It is only consulted when explicitly passed: deriving a mask on the default path would change behaviour for every existing caller and would require a real tokenizer where none is needed today. On the inputs_embeds and encoder-decoder paths the mask keeps flowing through to the model untouched, as it did when it arrived via **multimodal_kwargs -- image processors emit one alongside pixel_values and callers forward the lot. Verified across 3 architectures x 3 padding amounts x 2 cache settings, via both the explicit mask and padding_side. Only the mask can express an interior gap or a pad id that also occurs as a real token. Refs TransformerLensOrg#1612 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| initial_attention_mask = None | ||
| if initial_attention_mask is not None: | ||
| if initial_attention_mask.shape != input_tokens.shape: | ||
| raise ValueError( |
There was a problem hiding this comment.
The heuristic requires isinstance(self.tokenizer, PreTrainedTokenizerBase) and a pad id, so on a boot_native bridge (tokenizer None) an explicit generate(tokens, padding_side="left") silently changes nothing. That's the same failure this PR fixes for real tokenizers, on a separate bridge. Since the gate only fires when the caller explicitly passed padding_side, could you raise a legible ValueError when it can't be honored, pointing at attention_mask as the alternative?
| ``padding_side`` heuristic, and unlike it can express an interior gap or | ||
| a pad id that also occurs as a real token. Passing ``padding_side`` | ||
| instead reads the padding off the pad token, which is enough for the | ||
| common single-edge case. Not supported for encoder-decoder or |
There was a problem hiding this comment.
On those paths the mask is routed back through multimodal_kwargs untouched. This is a behavior you chose for Florence-2-style processor passthrough. "Not supported" reads as "will raise". Could you reword to something like "on encoder-decoder and inputs_embeds paths the mask is forwarded to the model as-is (not grown per step)" so the docstring matches the code and the test?
Reading the padding off the tokens needs a tokenizer with a pad id. Without one -- a boot_native bridge, say -- the argument was inert, which left exactly the bug this PR fixes, silently, on a different kind of bridge. Since the heuristic only runs when the caller explicitly passed padding_side, say so instead: two ValueErrors, one for a missing tokenizer and one for a missing pad_token_id, each naming attention_mask as the way through. Verified that the alternative works, with a left-padded boot_native prompt generating the same continuation as the unpadded one. Also correct the attention_mask docstring. It still said the parameter was "not supported for encoder-decoder or inputs_embeds generation", left over from the version that raised on those paths; the code forwards the mask to the model as-is there, which is what the test asserts and what processors emitting one alongside pixel_values expect. Refs TransformerLensOrg#1612 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both fixed in On the first one, you were right and it's a sharper version of the bug: on a bridge with no tokenizer the argument was inert, so I also checked that the alternative the message advertises actually holds rather than assuming it: a left-padded On the second — that wording was left over from the version that raised on those paths, and I never updated it when I switched to pass-through. It now reads "On the encoder-decoder and inputs_embeds paths the mask is forwarded to the model as-is rather than grown per step, which is what processors emitting one alongside Three tests added, 20 in the file. Full unit + integration + acceptance: 6452 passed, with the same pre-existing |
The isinstance check added in a81a323 referenced the class without importing it, so mypy failed in CI with "Name PreTrainedTokenizerBase is not defined". It passed locally against a stale incremental cache, which was checking 422 files where CI checks 424; a cleared cache reproduces the error exactly. Refs TransformerLensOrg#1612 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Great stuff @sohv, solid resolution, thanks for getting all that updated! Approved and merging now |
generate()had no way to be told which prompt tokens are padding, so an already-padded token tensor generated as though its pads were real context. I found the old behaviour was stranger than I described in the issue: anattention_maskpassed by a hopeful caller was absorbed into**multimodal_kwargs, which are merged into the forward kwargs on step 0 only. The first token therefore came out right and every later one drifted, which is harder to spot than a uniform failure.What I changed
attention_maskis now an explicit parameter, extended by one attended column per generated token, so every step sees a mask spanning the prompt plus what has been generated so far. I also fixed the cached step, which pinnedposition_idstototal_len - 1— that counts pad slots, so it is wrong for a left-padded prompt. It now reads the new token's position off the mask.Separately,
padding_sideis now applied to token input.generate()has always documented the argument but only used it when tokenizing string or list input, leaving it inert for a tensor. It is consulted only when explicitly passed, because deriving a mask on the default path would change behaviour for every existing caller and would require a real tokenizer where none is needed today. That covers the common single-edge case; only an explicit mask can express an interior gap or a pad id that also occurs as a real token.Verification
Correct across 3 architectures × 3 padding amounts × 2 cache settings, via both routes:
The no-mask result is unchanged from before this PR, so nothing moves for callers who don't opt in.
17 tests in a new file, 11 red on unmodified code. One of them spies on the forward and asserts the mask's width and masked region at every step, which is the part the old step-0 leak got wrong.
Full unit + integration + acceptance: 6449 passed, 1 failed. The failure is
test_bridge_hooked_parity_multi_step_optimization, which produces the identical0.100004 exceeds threshold 0.100000on unmodified code. I also re-ran the areas closest to this change on their own: all generation suites (129 passed) and every multimodal adapter (35 passed). mypy clean.Two notes
I did not touch
HookedTransformer, per your note. Nothing here depends on it — the accurate framing is just that the bridge's ownpadding_sideargument was inert for token input.I left
**multimodal_kwargsalone for now, for the reason you raised. GPT-2's forward takes**kwargs, so a signature-based check never fires, and any whitelist risks trimming kwargs that should pass through. Worth its own investigation. Makingattention_maskexplicit already removes the specific silent-swallow from the issue.One thing that caught me while testing, and which shaped the final design: the Florence-2 benchmark passes a processor's outputs straight through as
**extra, includingattention_mask. My first version rejected a mask on encoder-decoder paths, and thatNotImplementedErrorlanded inside a bareexcept Exception: continue— surfacing as a misleading "image processor/PIL unavailable" skip rather than an error. On theinputs_embedsand encoder-decoder paths the mask now flows through to the model untouched, exactly as it did via**multimodal_kwargs.