Skip to content

fix(bridge): derive position_ids from attention_mask for left-padded input - #1610

Open
sohv wants to merge 1 commit into
TransformerLensOrg:dev-4.xfrom
sohv:fix/bridge-left-padding-positions
Open

fix(bridge): derive position_ids from attention_mask for left-padded input#1610
sohv wants to merge 1 commit into
TransformerLensOrg:dev-4.xfrom
sohv:fix/bridge-left-padding-positions

Conversation

@sohv

@sohv sohv commented Aug 5, 2026

Copy link
Copy Markdown

Description

Fixes #1609.

TransformerBridge.forward() did not derive position_ids from a supplied attention_mask, so left-padded input silently got the wrong absolute positions — no error, no NaN, just wrong logits and a wrong loss.

On gpt2, one prompt, mask supplied:

n_pad HT loss Bridge loss (before) HT drift Bridge drift
0 4.503170 4.503170
1 4.503169 13.594296 1.4e-06 9.09
3 4.503169 11.154946 9.5e-07 6.65
8 4.503169 10.787075 1.4e-06 6.28

Right padding was never affected (drift ≤ 9.5e-07) — causality already protects it.

transformer_bridge.py derived position_ids only for batched list input, so pre-tokenized tensors fell through to HF's plain arange and the padding offset was never removed. This extends the same correction that branch already applies. An explicitly supplied position_ids still wins.

Two consequences this also fixes:

  • The bridge was inconsistent with itself — the same batch gave different logits depending on whether it was passed as strings or token IDs (max |logit diff| 4.142e+01).
  • enable_compatibility_mode(), which documents "HookedTransformer-equivalent numerics", matched HT exactly on unpadded input (0.000e+00) but diverged on left-padded input.

Tests

Adds tests/integration/model_bridge/test_left_padding_positions.py: logit invariance under both padding sides, the same property in compatibility mode, and agreement between the derived and an explicitly supplied position_ids.

Red-before / green-after: 9 passed with the fix, 5 failed without it. The right-padding cases are controls — they pass in both states, so the tests are specific to the bug rather than to padding in general.

These sit in the integration tier rather than the unit tier deliberately: left padding produces a fully masked query row, which the Native attention path turns into NaN until the masked-softmax fix in #1608 lands, so boot_native cannot express the property yet.

Verification

  • New tests: 9 passed / 5 failed without the fix
  • tests/unit/model_bridge + tests/unit/test_tokenizer_padding_side.py: 3955 passed, 27 skipped, 10 xfailed
  • pycln / isort / black clean; mypy clean

Relationship to #1607 / #1608

Independent bugs on the same path that compound. This is measured across four states (gpt2, aggregate loss on a left-padded batch; HT reference 4.814578):

state batch loss max |logit diff|
dev-4.x 7.254170 1.361e+02
#1608 alone 7.411356 1.361e+02
this PR alone 6.688155 9.918e-05
both 4.814578 9.918e-05

This PR fixes the logits; #1608 fixes the loss aggregation. Batched loss is only correct with both, so reviewing this one in isolation will still show a wrong aggregate. No file overlap, so they merge in either order.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Checklist:

  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective
  • New and existing unit tests pass locally with my changes
  • I have not rewritten tests relating to key interfaces which would affect backward compatibility

@jlarson4 jlarson4 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.

Thanks for both discovering and resolving this bug @sohv! Great work. Just a couple review comments below, let me know if you have any questions

# than left to HF's default arange. HookedTransformer does this via
# pos_embed; without it the bridge silently returns wrong logits.
if (
attention_mask is not None

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.

A manual forward(new_token, attention_mask=<full mask>, past_key_values=cache) with left padding returns logits, but raises RuntimeError: The size of tensor a (19) must match the size of tensor b (10) because the derived position_ids spans past+new while input_ids is only the new token. Can the derivation be limited to the tokens actually being passed, the way get_offset_position_ids does it (utilities/tensors.py:131), with a test covering a cached step?

and "position_ids" not in kwargs
and not _is_inputs_embeds
and attention_mask.ndim == 2
and bool((attention_mask[:, 0] == 0).any())

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.

The gate tests only column 0, so a mask with an interior gap and no leading padding still diverges from HookedTransformer. I measured 3.631e+00 on gpt2 in compat mode against a 0.000e+00 unpadded control. Would it be possible to widen this to any mask with a gap?

@sohv
sohv force-pushed the fix/bridge-left-padding-positions branch from c37a8d8 to cdc2af2 Compare August 5, 2026 21:35
@sohv

sohv commented Aug 5, 2026

Copy link
Copy Markdown
Author

Thank you @jlarson4 for bringing these issues to my attention both were real. I reproduced these issues as you described and have pushed the revised code in commit cdc2af2.

Instead of separately patching the two cases, I switched the derivation to utils.get_offset_position_ids, the helper PosEmbed and AbstractAttention already use. That resolves both comments at once and means the bridge shares HookedTransformer's position derivation instead of running a parallel one, which is really what it should have been doing from the start.

Cached step. Reproduced: RuntimeError: The size of tensor a (27) must match the size of tensor b (14). Now offset by the cached prefix — past_kv_pos_offset = attention_mask.shape[1] - input_ids.shape[1] — so the positions are sliced back to the tokens actually being passed, matching how the helper is used elsewhere. Covered by test_cached_step_with_left_padding.

Interior gap. Reproduced: 6.752e+00 on gpt2 in compat mode against a 0.000e+00 unpadded control, with the gate correctly not firing. Rather than widen the predicate I dropped it — an all-ones mask gives cumsum - 1 == arange, so deriving unconditionally is a provable no-op when there's no padding, and there's no gate left to get subtly wrong. Now 0.000e+00. Covered by test_interior_mask_gap_uses_derived_positions, parametrised over two gap positions.

One thing you did not flag that this surfaced: my pad-position convention was also wrong. I had masked_fill(mask == 0, 1); the helper uses masked_fill(shifted < 0, 0), so pads inherit the previous real index rather than a constant. One of my own tests failed on the switch- it had encoded my convention instead of the contract. So I fixed the test and now test_derived_position_ids_match_hooked_transformer pins it to the shared helper so it can't drift again.

Verification:

  • The two repros above: clean.
  • Test file: 13 passed with the fix, 7 failed without it, and 4 fail against the previous commit (c37a8d8) — specifically the cached-step, both interior-gap, and convention tests, so they do guard what you reported rather than just passing.
  • tests/unit/model_bridge + test_tokenizer_padding_side.py + test_utils.py: 4038 passed, 27 skipped, 10 xfailed.
  • Stacked with Fix masked causal loss in TransformerBridge #1608, the left-padded batch loss is 4.814578 against HookedTransformer's 4.814578, per-row drift 0.00000.

One correction to the PR description: the "this PR alone" row in the compounding table was 6.688155 and is now 5.396149, since pad positions inherit the previous index and that changes the still-unmasked aggregate #1607 produces. The "both" row is unchanged at 4.814578.

…input

TransformerBridge.forward() did not derive position_ids from a supplied
attention_mask, so masked-out tokens silently shifted the absolute position of
every real token after them — no error, no NaN, just wrong logits and a wrong
loss. On gpt2 the loss for one prompt moved from 4.503170 unpadded to 11.154946
with three left pads, while HookedTransformer stays invariant (drift ~1e-06).

transformer_bridge.py derived position_ids only for batched *list* input, so
pre-tokenized tensors fell through to HF's plain arange and the offset was never
removed. This reuses utils.get_offset_position_ids — the same helper PosEmbed
and AbstractAttention already use — so the bridge shares HookedTransformer's
position derivation rather than paralleling it. An explicitly supplied
position_ids still wins.

The derivation fires only when the mask actually moves an attended token off its
default position, i.e. when some masked token precedes a real one. That covers
left padding and interior mask gaps. Pure right padding and all-ones masks
already agree with arange, so they are left alone: injecting position_ids there
is a no-op at best, and breaks models whose forward does not accept the argument
or which compute their own position streams (multimodal mRoPE).

With a KV cache the mask spans past+new while input_ids holds only the new
tokens, so the derived positions are sliced back to the tokens being passed.

The bridge was also inconsistent with itself before this — the same batch gave
different logits depending on whether it was passed as strings or token IDs
(max |logit diff| 4.142e+01) — and enable_compatibility_mode(), which documents
"HookedTransformer-equivalent numerics", diverged on left-padded input while
matching exactly on unpadded input.

Adds integration regression tests: logit invariance under both padding sides,
the same property in compatibility mode, agreement with the shared helper,
precedence of an explicit position_ids, interior mask gaps, a cached decode
step, and that no position_ids are injected when the mask does not require it.
Right-padding cases are controls that pass with and without the fix. They live
in the integration tier because left padding produces a fully masked query row,
which the Native attention path turns into NaN until the masked-softmax fix in
TransformerLensOrg#1608 lands.

Fixes TransformerLensOrg#1609.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sohv
sohv force-pushed the fix/bridge-left-padding-positions branch from cdc2af2 to 32dd94d Compare August 5, 2026 22:39
@sohv

sohv commented Aug 5, 2026

Copy link
Copy Markdown
Author

CI caught a regression in my last push so this needed a third pass.

Dropping the gate entirely was an over-correction. You asked me to widen it to any mask with a gap; I widened it to any mask at all, which made the derivation fire for cases that never needed it. Right padding and all-ones masks already agree with arange, so injecting position_ids there is a no-op at best — and at worst it breaks models that don't accept the argument (TinyLLaDAModelLM.forward() got an unexpected keyword argument 'position_ids') or that compute their own position streams (the four multimodal HF-parity tests, where naive positions overrode mRoPE). I confirmed those five were mine by reproducing them against that commit: the Qwen2.5-VL diff came back as 0.00014135, matching the CI number.

Fixed in 32dd94d: the derivation now fires only when the mask actually moves an attended token off its default position — some masked token precedes a real one. That is exactly left padding and interior gaps, and excludes right padding and all-ones masks.

  • LLaDA right-padding test: passes
  • Qwen2.5-VL adapter suite: 5 passed
  • Your two findings: still fixed — the cached step returns clean logits, and interior gaps measure 0.000e+00 across three gap positions with no leading padding
  • Added test_no_position_ids_injected_when_unnecessary, parametrised over all-ones and right-padded masks, which fails against the previous commit — so this specific over-firing can't come back
  • tests/unit/model_bridge + the LLaDA adapter + the new file: 4070 passed, 27 skipped, 10 xfailed

One unrelated failure in that run: test_bridge_hooked_parity_multi_step_optimization. It doesn't pass an attention_mask, so this code path can't reach it, and it fails identically on pristine dev-4.x with no fix applied (Step 10: 0.100004 exceeds threshold 0.100000). CI tripped it at step 1 by ~3%, my machine trips it at step 10 by 0.004% — a knife-edge threshold rather than anything this PR touches. Happy to open a separate issue if that's useful.

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.

2 participants