Skip to content

Commit 32accd1

Browse files
authored
Merge pull request #132 from KempnerInstitute/feat/mask-padded-frames
Mask padded video frames from attention
2 parents 3ad9d9a + 2670fd9 commit 32accd1

13 files changed

Lines changed: 364 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6565
- **Video data path (pluggable, registry-driven).** `kempnerforge/data/video_io.py`: timestamp-based frame sampling (target fps, first & last frame kept — Molmo2 §3.1/§A) registered as the `"uniform"` sampling policy and selectable via `[video].sampling_policy`; PyAV decode (lazy-imported). `kempnerforge/data/video_dataset.py`: a `VideoDataset` base + the WebVid-style `WebVidVideoDataset` (CSV manifest + `id[:2]/id[:4]/id[:6]/id.mp4` mapping) registered as `"webvid"` via `@registry.register_video_dataset`, plus a `build_video_dataset` dispatch — so other dataset styles are additive registrations selected by `[video].dataset_type`. The WebVid corpus directory is parameterized by `[video].dataset_name` (no longer hardcoded to `webvid-10M`). `VideoCollator` → `(B, F, 3, H, W)` + a frame-validity mask; an undecodable clip is masked out (no loss). `kempnerforge/config/registry.py`: `register_video_dataset` / `register_sampling_policy` registries. `kempnerforge/config/video.py`: the `[video]` `VideoConfig` section (`data_root`, `dataset_type`, `dataset_name`, `sampling_policy`, `split`, `fps`, `max_frames`, `min_frames`, `frame_size`, `max_samples`), wired into `JobConfig` (+ `is_video`). `av` is an optional `video` dependency group (`uv sync --group video`); CI installs it for the lint + unit-test jobs.
6666
- **Frame-aware model + training wiring.** `kempnerforge/model/vlm.py`: `_project_image_features``_project_visual_features` folds the frame axis through the encoder + pooler to `(B, F·P′, dim)` (a single image is the `F == 1` case). `VLMWrapper` gains `frames_per_clip`, threaded through `build_parallel_model` / `_build_vlm` / `build_vlm_wrapper` so the static visual-token count equals `F·P′` (drives the residual budget and MoT's positional split; static == runtime). `scripts/train.py` builds the video dataset/collator when `[video]` is set. Adds `configs/train/vlm_video_webvid.toml` (SigLIP2 + avgpool + WebVid).
6767
- Tests: `tests/unit/test_video_io.py`, `test_video_dataset.py`, `test_video_config.py`; video-forward cases (all four archs) + image-path regression in `test_vlm.py`; pooling-adapter cases in `test_adapter.py`. Docs: `docs/how-to/train-on-video.md`.
68-
- Deferred (follow-ups; the registries make these additive): more video dataset styles (HuggingFace video sets, flat folders, alternate manifests) and frame-sampling policies; per-frame timestamp tokens + grounding (`<points>`/`<tracks>` outputs with point-F1 / track-J&F eval), frame-mask-aware attention, bidirectional visual attention, VLM sequence packing, long-context (blocked on context-parallel being wired), and warm-start from a converted image-VLM checkpoint.
68+
- Deferred (follow-ups; the registries make these additive): more video dataset styles (HuggingFace video sets, flat folders, alternate manifests) and frame-sampling policies; per-frame timestamp tokens + grounding (`<points>`/`<tracks>` outputs with point-F1 / track-J&F eval), bidirectional visual attention, VLM sequence packing, long-context (blocked on context-parallel being wired), and warm-start from a converted image-VLM checkpoint.
69+
- **Padded frames masked from attention (all four archs).** Short/undecodable video clips pad to `max_frames` with blank frames; the `frame_mask` is now consumed so real tokens never attend to padded-frame visual tokens. One per-token validity mask, `ModalityContext.key_padding_mask` `(B, S)`, threads through the model: the shared `Attention` ANDs it with the causal (and doc) mask — covering Joint-Decoder and MoMa — `MoTAttention` builds an explicit causal-AND-valid mask, and Cross-Attention masks the padded image K/V via its existing `image_mask`. A NaN guard unmasks fully-masked query rows (an all-padded clip) so softmax stays finite. It is a **pure mask — no new state-dict keys**, so checkpoints stay compatible both ways; the image (`F=1`) and text paths are unchanged (no mask is built, so they keep the FlashAttention-2 path). Note: for the image-prefix arches (Joint-Decoder/MoT/MoMa) video self-attention always takes the explicit-mask SDPA path (FA2 disabled, a `(B,1,S,S)` mask materialized) even for fully-decoded clips — the result is identical to causal-only but not free; a deliberate `torch.compile`/DP-friendly trade-off (one graph, no host sync), with FA2-recovery / FlexAttention left as a follow-up. (Cross-Attention keeps FA2 on its text self-attention and only masks padded image K/V in the cross-attention blocks.) Foundation for variable-length / mixed image+video batches.
70+
- `kempnerforge/model/modality.py`: `ModalityContext.key_padding_mask` field (+ invariant). `kempnerforge/model/vlm.py`: `_visual_token_mask` expands `frame_mask (B,F)``(B, F·P′)`; the four strategies place it (image-prefix arches → `key_padding_mask`, Cross-Attention → `image_mask`). `attention.py` / `mot.py` / `cross_attention.py` consume the mask (+ NaN guard); `moma.py`'s `MoMaFFN` also excludes padded positions from expert-choice routing (so padded tokens never consume expert capacity). `scripts/train.py` threads `batch["frame_mask"]` into the forward.
71+
- Deferred: MoT configured with an *MoE* FFN still routes padded tokens through the shared `MoEMLP` — a follow-up ("generic token-validity in MoE") would mask that and padded text alike. MoT-dense (the default) and MoMa are fully masked.
72+
- Tests: `tests/unit/test_vlm.py` (per-arch masking invariance, image no-op, undecodable-clip NaN guard, mask expansion); `test_moma.py` (FFN routing exclusion); `test_modality_context.py` (the new invariant).
6973
- `install-and-verify` plugin skill: runs `uv sync`, asserts Python ≥ 3.12, then runs the four CI gate checks (`ruff check`, `ruff format --check`, `pyright`, `pytest tests/unit/`). Canonical first command after cloning.
7074
- `.python-version` pinned to `>=3.12` so uv resolves the interpreter explicitly. Teammates on 3.13 use 3.13 (no download); 3.11-only users get 3.12 auto-fetched.
7175
- **Dynamic-checkpointing window** (`[checkpoint.dyn_ckpt_window]`). Opt-in dense save phase: inside `[start, stop]` a registered strategy decides which steps to save; outside the window the regular `interval` cadence applies. The default strategy, `"power2"`, saves at `start` and at every `start + 2^k` while `<= stop` — tight near the start of the window, doubling thereafter. Useful for analyzing early-training dynamics, where the loss moves fastest. The default `CheckpointConfig` is unchanged (no `dyn_ckpt_window`, interval-only saves).

docs/how-to/train-on-video.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,19 @@ time, so it is set in the TOML, not via a `--vlm.arch=` CLI override.)
102102
- **Causal attention; no per-frame timestamps yet** — temporal order is frame
103103
order. Per-frame timestamp tokens + grounding (`<points>`/`<tracks>` outputs
104104
with point-F1 / track-J&F eval) are a follow-up.
105-
- **Padded frames are not yet masked from attention** — short clips pad to
106-
`max_frames` with blank frames; a `frame_mask` is produced but not yet
107-
consumed by the attention mask.
105+
- **Padded frames are masked from attention** — short/undecodable clips pad to
106+
`max_frames` with blank frames, and the `frame_mask` is consumed so real
107+
tokens never attend to padded-frame visual tokens (MoMa also drops them from
108+
expert-choice routing); a NaN guard keeps an all-padded clip finite. It is a
109+
pure mask (no new checkpoint keys); image/text keep the FlashAttention-2 path.
110+
For the image-prefix arches (Joint-Decoder/MoT/MoMa), video self-attention
111+
always takes the explicit-mask SDPA path (FA2 disabled, a `(B,1,S,S)` mask
112+
built) even for fully-decoded clips — a deliberate compile/DP-friendly
113+
trade-off; recovering FA2 / FlexAttention is a follow-up. (Cross-Attention
114+
keeps FA2 on its text self-attention; it masks padded image K/V in the
115+
cross-attention blocks instead.) *Remaining:* MoT
116+
configured with an MoE FFN still routes padded tokens through the shared MoE
117+
(a "generic token-validity in MoE" follow-up).
108118
- **Fixed `F` per batch** keeps tensor shapes static (for `torch.compile` and
109119
DP-rank consistency); variable-length clips arrive with VLM sequence packing.
110120
- **Long-context** (many frames) is blocked on context-parallel being wired.

kempnerforge/model/attention.py

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ def forward(
121121
*,
122122
kv_cache: KVCache | None = None,
123123
doc_ids: torch.Tensor | None = None,
124+
key_padding_mask: torch.Tensor | None = None,
124125
) -> torch.Tensor:
125126
"""Forward pass.
126127
@@ -132,6 +133,11 @@ def forward(
132133
doc_ids: Optional per-token document IDs for packed sequences,
133134
shape (batch, seq_len). When provided, constructs a block-diagonal
134135
causal mask so tokens only attend within their document.
136+
key_padding_mask: Optional per-key validity mask, shape
137+
(batch, seq_len); ``True`` = attend, ``False`` = drop (e.g. the
138+
visual tokens of padded video frames). Combined with the causal
139+
(and doc) mask; fully-masked query rows are unmasked to keep
140+
softmax finite.
135141
136142
Returns:
137143
Output tensor of shape (batch, seq_len, dim).
@@ -177,14 +183,46 @@ def forward(
177183
# restrict attention to only the first key position).
178184
if self.capture_attention_weights:
179185
# Manual attention for weight extraction (analysis only, not for training)
180-
out, attn_weights = self._attention_with_weights(q, k, v, seq_len, doc_ids, kv_cache)
186+
out, attn_weights = self._attention_with_weights(
187+
q, k, v, seq_len, doc_ids, kv_cache, key_padding_mask
188+
)
181189
self.last_attention_weights = attn_weights.detach().cpu()
182-
elif doc_ids is not None:
190+
elif doc_ids is not None or key_padding_mask is not None:
191+
# An explicit attn_mask is not a FlashAttention-2 shape, so SDPA falls
192+
# back to the mem-efficient/math kernel here. The image-prefix video
193+
# arches (Joint-Decoder/MoMa here, MoT in mot.py) always pass a
194+
# key_padding_mask (all-True when unpadded), so their self-attention
195+
# always takes this branch -- losing FA2 and materializing a (B, 1, S, S)
196+
# mask even for fully-decoded clips. (Cross-Attention sets no
197+
# key_padding_mask on this text self-attention -- it masks padded image
198+
# K/V in the cross-attention blocks instead -- so it keeps FA2 here.)
199+
# Deliberate: always-masking is torch.compile / DP-friendly (one graph,
200+
# no host sync). Recovering FA2 for unpadded batches (or moving to
201+
# FlexAttention) is a follow-up.
202+
#
203+
# Asserts no kv_cache: neither doc_ids (packed training) nor
204+
# key_padding_mask (VLM video) co-occurs with decode today, and this
205+
# branch's full-sequence causal mask would mis-handle a cached
206+
# (seq_len=1) decode rather than attend to all cached positions.
207+
assert kv_cache is None, (
208+
"doc_ids / key_padding_mask are not supported with kv_cache decode "
209+
"(would build an incorrect causal mask)."
210+
)
183211
seq_len_kv = k.shape[2]
184-
# Block-diagonal mask: same-document AND causal
185-
doc_mask = doc_ids.unsqueeze(2) == doc_ids.unsqueeze(1) # (B, S, S)
212+
# Explicit bool mask: causal, AND same-document (doc_ids), AND valid
213+
# keys (key_padding_mask, e.g. dropping padded video frames' tokens).
186214
causal = torch.ones(seq_len, seq_len_kv, dtype=torch.bool, device=q.device).tril()
187-
attn_mask = (doc_mask & causal).unsqueeze(1) # (B, 1, S, S)
215+
attn_mask = causal.unsqueeze(0).unsqueeze(0) # (1, 1, S, S_kv)
216+
if doc_ids is not None:
217+
doc_mask = (doc_ids.unsqueeze(2) == doc_ids.unsqueeze(1)).unsqueeze(1) # (B,1,S,S)
218+
attn_mask = attn_mask & doc_mask
219+
if key_padding_mask is not None:
220+
attn_mask = attn_mask & key_padding_mask.view(batch, 1, 1, seq_len_kv)
221+
# NaN guard: a query row with no reachable valid key (e.g. the leading
222+
# positions of an all-padded / undecodable clip) would softmax over all
223+
# -inf -> NaN. Unmask such rows; their outputs are discarded (trimmed by
224+
# output_slice, or the clip's labels are all -100).
225+
attn_mask = attn_mask | ~attn_mask.any(dim=-1, keepdim=True)
188226
with self._sdpa_context():
189227
out = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask)
190228
else:
@@ -205,6 +243,7 @@ def _attention_with_weights(
205243
seq_len: int,
206244
doc_ids: torch.Tensor | None,
207245
kv_cache: KVCache | None,
246+
key_padding_mask: torch.Tensor | None = None,
208247
) -> tuple[torch.Tensor, torch.Tensor]:
209248
"""Compute attention output and weights manually (for analysis).
210249
@@ -221,11 +260,15 @@ def _attention_with_weights(
221260
attn = torch.matmul(q, k.transpose(-2, -1)) * scale
222261

223262
seq_len_kv = k.shape[2]
224-
if doc_ids is not None:
225-
doc_mask = doc_ids.unsqueeze(2) == doc_ids.unsqueeze(1)
263+
if doc_ids is not None or key_padding_mask is not None:
226264
causal = torch.ones(seq_len, seq_len_kv, dtype=torch.bool, device=q.device).tril()
227-
mask = ~(doc_mask & causal).unsqueeze(1)
228-
attn = attn.masked_fill(mask, float("-inf"))
265+
valid = causal.unsqueeze(0).unsqueeze(0) # (1, 1, S, S_kv)
266+
if doc_ids is not None:
267+
valid = valid & (doc_ids.unsqueeze(2) == doc_ids.unsqueeze(1)).unsqueeze(1)
268+
if key_padding_mask is not None:
269+
valid = valid & key_padding_mask.view(q.shape[0], 1, 1, seq_len_kv)
270+
valid = valid | ~valid.any(dim=-1, keepdim=True) # NaN guard (see forward)
271+
attn = attn.masked_fill(~valid, float("-inf"))
229272
elif kv_cache is None or seq_len > 1:
230273
causal = torch.ones(seq_len, seq_len_kv, dtype=torch.bool, device=q.device).triu(
231274
diagonal=1

kempnerforge/model/cross_attention.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,12 @@ def forward(
101101
# the right thing across heads and text Q positions.
102102
attn_mask = None
103103
if image_mask is not None:
104-
attn_mask = image_mask.view(batch, 1, 1, num_image_tokens)
104+
# NaN guard: a sample with no valid image tokens (e.g. an undecodable
105+
# clip — all frames padded) would softmax over all -inf -> NaN. Unmask
106+
# such rows so softmax stays finite; their text outputs are discarded
107+
# (the clip's labels are all -100).
108+
safe = image_mask | ~image_mask.any(dim=1, keepdim=True) # (B, N)
109+
attn_mask = safe.view(batch, 1, 1, num_image_tokens)
105110

106111
# Cross-attention: no causal mask on the image axis.
107112
out = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask, is_causal=False)

kempnerforge/model/modality.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ class ModalityContext:
4646
error).
4747
- ``modality_ids`` requires ``prefix_embeds`` or ``inputs_embeds``
4848
to be set (routing without a residual extension is meaningless).
49+
- ``key_padding_mask`` requires ``prefix_embeds`` or ``inputs_embeds``
50+
to be set (it is a key-validity mask over the residual sequence; the
51+
Cross-Attention arch masks image K/V via ``image_mask`` instead).
4952
5053
``output_slice`` composes with the ``tokens`` path AND with the
5154
``inputs_embeds`` path; it is not constrained intra-context. The
@@ -59,6 +62,7 @@ class ModalityContext:
5962
image_features: torch.Tensor | None = None
6063
image_mask: torch.Tensor | None = None
6164
modality_ids: torch.Tensor | None = None
65+
key_padding_mask: torch.Tensor | None = None
6266

6367
def __post_init__(self) -> None:
6468
residual_routes = sum(
@@ -80,3 +84,12 @@ def __post_init__(self) -> None:
8084
"ModalityContext: modality_ids requires prefix_embeds OR "
8185
"inputs_embeds to be set (routing without a residual extension is meaningless)"
8286
)
87+
if (
88+
self.key_padding_mask is not None
89+
and self.prefix_embeds is None
90+
and self.inputs_embeds is None
91+
):
92+
raise ValueError(
93+
"ModalityContext: key_padding_mask requires prefix_embeds OR inputs_embeds "
94+
"to be set (it masks the residual sequence; Cross-Attention uses image_mask)"
95+
)

0 commit comments

Comments
 (0)