Optimize flash attention max seqlen computation in vision attention#47170
Conversation
vasqu
left a comment
There was a problem hiding this comment.
Imo totally valid and makes sense to avoid a per layer call
Only thing I would change design wise would be to move things to vision utils as well and make similar skips as when the kwarg already exists. cc @IlyasMoutawwakil @zucchini-nlp
be190be to
16876e4
Compare
Thanks for the suggestion! |
| def get_vision_max_seqlen(cu_seqlens: torch.Tensor, kwargs: dict | None = None, kwarg_name: str = "max_seqlen") -> int: | ||
| """Get the maximum packed sequence length, or pop it from `kwargs` if precomputed. | ||
|
|
||
| Args: | ||
| cu_seqlens: `(num_sequences + 1,)` cumulative sequence boundaries. | ||
| kwargs: optional caller kwargs containing a precomputed maximum sequence length. | ||
| kwarg_name: key used to pop the precomputed value from `kwargs`. | ||
|
|
||
| Returns: | ||
| Maximum packed sequence length as a Python integer. | ||
| """ | ||
| if kwargs is not None and (max_seqlen := kwargs.pop(kwarg_name, None)) is not None: | ||
| return max_seqlen | ||
| return int((cu_seqlens[1:] - cu_seqlens[:-1]).max().item()) |
There was a problem hiding this comment.
why not pass the model config to it, to decide whether it should:
- return it from kwarsgs if it's there
- return None if unnecessary (non flash attn)
- compute it and sync
to avoid the
max_seqlen = None
if falsh_attn(config)..
in multiple places
There was a problem hiding this comment.
another thing we can do is extend the old get_vision_cu_seqlens to return a tuple[tensor, int | none] since they are always used together or neither @vasqu
There was a problem hiding this comment.
Yea tbh, I don't see why we can't combine them. We should let the single version of cu seqlens live tho to be BC
There was a problem hiding this comment.
Agreed, this is cleaner. I updated get_vision_max_seqlen to take the model config and handle the decision internally, and added get_vision_attention_seqlens to return both values while keeping get_vision_cu_seqlens unchanged for BC.
| if max_seqlen is None: | ||
| max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max() |
There was a problem hiding this comment.
the goal of the changes is that by this point, if the model uses fa, max seqlens should be already computed
There was a problem hiding this comment.
i say let it fail loudly if not
There was a problem hiding this comment.
Yep agree, don't like the recompute in any case will make it hard to see on new models whether they used the utils properly
There was a problem hiding this comment.
Agreed. When Flash Attention is used, max_seqlen should already have been computed by this point. I removed the recomputation fallback, and it now raises a clear ValueError if the value is missing.
| hidden_states: torch.Tensor, | ||
| cu_seqlens: torch.Tensor, | ||
| position_embeddings: tuple[torch.Tensor, torch.Tensor], | ||
| max_seqlen: int | None = None, |
There was a problem hiding this comment.
can be left in **kwargs: Unpack[TransformersKwargs] since we do pass the kwargs to attn interface
There was a problem hiding this comment.
Done, max_seqlen is no longer declared by the pass-through Block/Encoder layers and just flows through **kwargs. It stays explicit only on Attention, where it’s actually consumed.
IlyasMoutawwakil
left a comment
There was a problem hiding this comment.
left a couple comments that should simplify it a bit, mostly nudging towards the same constrants we agreed upon in #45396:
- the vision util should abstract the repetitive if this is none
- the "extra" arguments should stay hidden in kwargs
Thanks, addressed the review comments:
|
| if max_seqlen is None: | ||
| raise ValueError("`max_seqlen` must be provided when using Flash Attention.") |
There was a problem hiding this comment.
doesn't the attn interface raise an error on its own when it's missing ?
There was a problem hiding this comment.
I checked the Flash Attention source. The native flash_attn_varlen_func requires cu_seqlens_q/k and max_seqlen_q/k together and would reject an incomplete call.
However, Transformers currently checks in _flash_attention_forward:
is_fa_with_varlen_kwargs = all(
value is not None
for value in (cu_seq_lens_q, cu_seq_lens_k, max_length_q, max_length_k)
)
Therefore, when cu_seq_lens_q/k are provided but max_length_q/k are None, Transformers does not call flash_attn_varlen_func and never reaches the native error. Without an attention mask or packed position IDs, it instead falls through to the fixed-length flash_attn_func, which can incorrectly allow attention across packed sequences.
I propose centralizing the validation and routing in _flash_attention_forward:
explicit_varlen_kwargs = (
cu_seq_lens_q,
cu_seq_lens_k,
max_length_q,
max_length_k,
)
has_any_varlen_kwargs = any(value is not None for value in explicit_varlen_kwargs)
has_all_varlen_kwargs = all(value is not None for value in explicit_varlen_kwargs)
if attention_mask is not None:
# Derive the complete varlen arguments internally.
elif has_any_varlen_kwargs:
if not has_all_varlen_kwargs:
raise ValueError(...)
# Use the complete precomputed varlen arguments.
elif is_fa_with_position_ids:
# Derive the complete varlen arguments from position_ids.
else:
# Fixed-length Flash Attention.
This makes the explicitly provided varlen arguments an all-or-none group, while leaving the attention_mask and packed position_ids paths unchanged. We can then remove the repeated checks from each model Attention implementation.
Would centralizing this validation in _flash_attention_forward and removing the per-model checks align with what you had in mind?
There was a problem hiding this comment.
Tbh I'm against too much validation here. It will make the code much more unreadable. This is kind of a power feature and we just have to guarantee that our vision utils do this properly.
For BC purposes we could still calculate the max seq len within the attention module with the vision utils
There was a problem hiding this comment.
After thinking it over, I agree that we shouldn’t raise here. For BC, if max_seqlen is missing, we should compute it with the helper. In the standard Transformers model path, it is already precomputed and passed down, so this fallback adds no extra max computation or synchronization. It only runs when users call a Block or Attention module directly, preserving the previous behavior.
There was a problem hiding this comment.
+1, seems a bit breaking and shouldn't require validation as we are sure that max_len is passed from the VisionModel
There was a problem hiding this comment.
I kept a fallback to preserve BC. The normal model path still precomputes max_seqlen, so the fallback won’t be hit there. It only handles direct Block/Attention calls where max_seqlen isn’t provided. I’ve updated all affected models accordingly.
| max_length_q=max_seqlen, | ||
| max_length_k=max_seqlen, |
There was a problem hiding this comment.
yeh i think we don't need to raise the error in every vision attn, we pass the max cuseqlens here so it should be raised by the attn interface when given None (if it isn't the case already)
| max_seqlen: int | None = None, | ||
| attention_mask: torch.Tensor | None = None, |
There was a problem hiding this comment.
for the sake of bc it should probably be added last in order
There was a problem hiding this comment.
max_seqlen was already a required argument in MiniCPMV4_6VisionAttention before this PR, in the same position before attention_mask. I only widened its type because non-FA paths can now explicitly pass None. To preserve the original signature more strictly, I’ll remove the default while keeping its existing position, avoiding any change to the positional argument mapping.
I also checked the other Attention signatures and found that some newly added max_seqlen parameters were inserted before existing parameters such as position_embeddings, which does introduce a positional BC issue. I’ll move those newly added optional parameters after the existing ones. Thanks for prompting this broader check.
There was a problem hiding this comment.
ah i commented on the wrong one i guess, i saw a max_seqlen added in between two args somewhere
There was a problem hiding this comment.
ah yes thanks ! just read the rest of your reply 😅
| max_seqlen: int | None = None, | ||
| position_embeddings: tuple[torch.Tensor, torch.Tensor] | None = None, |
There was a problem hiding this comment.
Super nice initiative, thanks! Cosmos3-Edge is close to merge, lets fix it as well as imo it'll be merged first
Seems like we just created a basic VisionRaggedInput data collator for qwen-style models. When we get the torch-ragged attn support, we might merge the input preparation as a single collating class?
| max_seqlen: int | None = None, | ||
| position_embeddings: tuple[torch.Tensor, torch.Tensor] | None = None, | ||
| **kwargs, |
There was a problem hiding this comment.
ha, i kinda assumed it was already moved to kwargs in prev PR for export
Imo we can put it as Unpack[TransformersKwargs], what do yall think?
There was a problem hiding this comment.
Hmm, it is a bit weird but I'd rather keep the full signature here to indicate that this is now required here. Also cu seqlens was needed for the non FA paths so would be weird to kwarg only max seqlen
There was a problem hiding this comment.
I think keeping max_seqlen explicit in the Attention signature is clearer here. Flash Attention requires it together with cu_seqlens, so exposing both makes the varlen input contract clear to callers. Keeping it optional preserves BC for direct Block/Attention calls, while still indicating that callers should pass the precomputed value when available. Intermediate Blocks can continue to pass it through **kwargs.
| if max_seqlen is None: | ||
| raise ValueError("`max_seqlen` must be provided when using Flash Attention.") |
There was a problem hiding this comment.
+1, seems a bit breaking and shouldn't require validation as we are sure that max_len is passed from the VisionModel
One more for my data collator approach, I feel validated 😆 ❤️ |
032aef5 to
2c35c80
Compare
|
Thank you for your contribution 🤗! CI Security Gate — automatic approval blockedThis PR was not automatically approved for CI because the security gate failed. Possible reasons:
See the workflow run for the exact violations. A maintainer can review and manually approve CI if a finding is a false positive. |
Thanks for the review! I rebased onto the latest main and added Cosmos3-Edge to the same precomputation and fallback flow. |
vasqu
left a comment
There was a problem hiding this comment.
Just some small comments left imo but this looks very solid now
| if max_seqlen is not None: | ||
| inputs["max_seqlen"] = max_seqlen |
There was a problem hiding this comment.
| if max_seqlen is not None: | |
| inputs["max_seqlen"] = max_seqlen | |
| inputs["max_seqlen"] = max_seqlen |
not sure but we can just ignore the none guard imo
| max_window_seqlen = get_vision_max_seqlen( | ||
| inputs["cu_window_seqlens"], model.config, kwargs=inputs, kwarg_name="max_window_seqlen" | ||
| ) | ||
| if max_window_seqlen is not None: | ||
| inputs["max_window_seqlen"] = max_window_seqlen |
There was a problem hiding this comment.
| max_window_seqlen = get_vision_max_seqlen( | |
| inputs["cu_window_seqlens"], model.config, kwargs=inputs, kwarg_name="max_window_seqlen" | |
| ) | |
| if max_window_seqlen is not None: | |
| inputs["max_window_seqlen"] = max_window_seqlen | |
| inputs["max_window_seqlen"] = get_vision_max_seqlen( | |
| inputs["cu_window_seqlens"], model.config, kwargs=inputs, kwarg_name="max_window_seqlen" | |
| ) |
same here no?
There was a problem hiding this comment.
Ok happens below as well, but not mentioning anymore everywhere
| if max_seqlen is None: | ||
| max_seqlen = get_vision_max_seqlen(cu_seqlens, self.config) |
There was a problem hiding this comment.
Imo, we should just have
| if max_seqlen is None: | |
| max_seqlen = get_vision_max_seqlen(cu_seqlens, self.config) | |
| max_seqlen = get_vision_max_seqlen(cu_seqlens, self.config) |
Elsewhere as well then
There was a problem hiding this comment.
I.e. we should allow to return the max seqlens that are already prepared within the function (cu seqlen does the same)
There was a problem hiding this comment.
get_vision_cu_seqlens implements get-or-compute directly from kwargs. get_vision_max_seqlen already does the same at the model level, but by the time we enter Attention, max_seqlen has been bound to the explicit parameter and is no longer in kwargs.
Therefore, replacing the current guard with:
max_seqlen = get_vision_max_seqlen(cu_seqlens, self.config)would ignore the precomputed value and recompute it in every layer. To preserve the explicit Attention signature, we would need to pass the already-bound value into the helper:
def get_vision_max_seqlen(
cu_seqlens,
config,
max_seqlen=None,
kwargs=None,
kwarg_name="max_seqlen",
):
if max_seqlen is None and kwargs is not None:
max_seqlen = kwargs.pop(kwarg_name, None)
if max_seqlen is not None:
return max_seqlen
if not is_flash_attention_requested(config):
return None
return int((cu_seqlens[1:] - cu_seqlens[:-1]).max().item())This would centralize the None check in the helper rather than remove it. Since you also preferred keeping max_seqlen explicit in the Attention signature earlier, I think the current explicit fallback is clearer than adding another way to pass the same value into the helper. Would keeping it as is make more sense to you?
There was a problem hiding this comment.
Can we not just slightly adjust the calls like max_seqlen = get_vision_max_seqlen(cu_seqlens, self.config, kwargs={"max_seq_len": max_seq_len})?
My goal here is to avoid the none guard just so that we return the existing max seqlen if possible
| if max_seqlens is not None: | ||
| max_seqlens = max_seqlens // 4 |
There was a problem hiding this comment.
This is a bit weird imo we dont need to assume this to not exist here, we already guaranteed it so no need to allow this to be none
There was a problem hiding this comment.
I think this guard is still needed for non-FA paths. get_vision_max_seqlen intentionally returns None for eager/SDPA, while get_downsampled_inputs is still called when use_vit_merger=True, including with the default SDPA implementation. Removing the guard would evaluate None // 4. Making the value always available would require an otherwise unnecessary max computation and synchronization for non-FA paths, so I’d prefer to keep the guard here.
There was a problem hiding this comment.
Gotcha, let's keep a small comment then tho?
There was a problem hiding this comment.
Oh yea we never really had actual tests tbh, I'd rather remove this and just verify with our slow tests
Thanks! I’ve addressed the remaining comments and pushed the updates. Appreciate the thorough review! |
vasqu
left a comment
There was a problem hiding this comment.
Happy with this state, @IlyasMoutawwakil if you could take a last look 🤗
| return F.pad(after_conv1.cumsum(0), (1, 0), value=0).to(torch.int32) | ||
|
|
||
|
|
||
| def get_audio_max_seqlen(cu_seqlens: torch.Tensor, config: PreTrainedConfig, kwargs: dict | None = None) -> int | None: |
There was a problem hiding this comment.
not on you, I remember audio being a bit too different which is why we opted for per file implementations maybe something we should push for @IlyasMoutawwakil @eustlb @ebezzam
| @@ -480,12 +486,16 @@ def _prepare_navit_vision_inputs(model: torch.nn.Module, inputs: dict[str, Any]) | |||
| grid_thw, spatial_merge_size=1, window_size=window_kernel_size[0], patch_size=1 | |||
| ) | |||
| inputs["merged_shape"] = get_vision_merged_shape(target_sizes, window_kernel_size) | |||
| cu_seqlens = torch.nn.functional.pad( | |||
| torch.cumsum(target_sizes[:, 0] * target_sizes[:, 1], dim=0, dtype=torch.int32), (1, 0) | |||
| ) | |||
| inputs["max_seqlen"] = get_vision_max_seqlen(cu_seqlens, model.config, kwargs=inputs) | |||
There was a problem hiding this comment.
can the max_seqlen be computed from inputs["cu_window_seqlens"] here ?
There was a problem hiding this comment.
Not quite: cu_window_seqlens describes the windowed attention used by the ViT merger, while this max_seqlen is for the encoder’s per-image packed attention and is based on the full h * w sequence length. These values differ when an image is larger than one window. The merger already derives its own maximum from window_h * window_w, so I think the current full-image cu_seqlens is still needed here.
| return max_seqlen | ||
| if not is_flash_attention_requested(config): | ||
| return None | ||
| return int((cu_seqlens[1:] - cu_seqlens[:-1]).max().item()) |
There was a problem hiding this comment.
is the int and item both necessary ?
There was a problem hiding this comment.
removed int conversion
| def get_audio_max_seqlen(cu_seqlens: torch.Tensor, config: PreTrainedConfig, kwargs: dict | None = None) -> int | None: | ||
| """Get the maximum packed audio sequence length, or pop it from `kwargs` if precomputed.""" | ||
| if kwargs is not None and (max_seqlen := kwargs.pop("max_seqlen", None)) is not None: | ||
| return max_seqlen | ||
| if not is_flash_attention_requested(config): | ||
| return None | ||
| return int((cu_seqlens[1:] - cu_seqlens[:-1]).max().item()) |
There was a problem hiding this comment.
max_seqlen is computed the same way independently of modality so not sure @vasqu maybe we should strip the utility from the keyword "vision" and use it everywhere ?
There was a problem hiding this comment.
It's just a bit weird because it would live in vision utils. Maybe we need multimodal_utils, vision, and audio?
There was a problem hiding this comment.
this can be just in utils.generic for now, but mm_utils also works, although might stay mostly empty
There was a problem hiding this comment.
I moved the modality-agnostic logic to utils.generic.get_max_seqlen, updated both vision and audio paths to use it, and removed the duplicated audio helpers.
|
[For maintainers] Suggested jobs to run (before merge) run-slow: cosmos3_edge, ernie4_5_vl_moe, exaone4_5, glm4v, glm4v_moe, glm_image, glm_ocr, hunyuan_vl, kimi_k25, minicpmv4_6, paddleocr_vl, qwen2_5_omni, qwen2_5_vl, qwen2_vl, qwen3_5, qwen3_5_moe |
CI recapDashboard: View test results in Grafana |
vasqu
left a comment
There was a problem hiding this comment.
Let's land this 🫡 nice effort, these obvious but huge gains are nice to see
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
* upstream/main: (39 commits) Remove deprecated training args and `is_fast` property (huggingface#46917) Consistent output shape from `get_image_features` (huggingface#46405) Fix multi-device mxfp4 dequantization race in `_convert_moe_packed_tensors` (huggingface#47423) fix failed test cases for qwen3_omni_moe model (huggingface#47449) Fix Hunyuan-VL PIL image resize parity with reference preprocessing (huggingface#47233) Move `value` padding into the attention interfaces that need it (huggingface#47451) Simplify function dispatch for linear attention (huggingface#47450) [cache] Allow sliding window layers to be roll-backed for speculative decoding (huggingface#47447) Fix double-shifted training loss in GitForCausalLM (huggingface#47395) Fix CohereASR training-loss double-shift (same as Moonshine fix huggingface#46784) (huggingface#46895) Warn when `group_by_length` is silently ignored for iterable datasets (huggingface#47379) Update bug report list (huggingface#46607) Fix shape mismatch in KyutaiSpeechToText `generate()` last window (huggingface#46952) Optimize flash attention max seqlen computation in vision attention (huggingface#47170) fix: remove unreachable return in special token builder (huggingface#47420) Add Harry to slow CI (huggingface#47454) BLT: vectorize patch length processing (huggingface#47385) Fix `TrackioCallback` fails to log evaluation metrics after training ends (huggingface#46935) [Kimi] add integration tests (huggingface#47383) Fix typo in `MusicgenForCausalLM.generate()` (huggingface#46974) ...
What does this PR do?
This PR optimizes Flash Attention variable-length paths by precomputing the maximum sequence length once at the model/encoder level after
cu_seqlensis built, then passing it down through blocks into each attention layer.Why
Previously, several vision/audio attention implementations recomputed:
inside every attention layer on every forward pass.
For Flash Attention varlen kernels, max_seqlen_q and max_seqlen_k are ultimately consumed as Python integer arguments. In eager mode, passing a 0-dimensional CUDA tensor can therefore move the scalar conversion to the Flash Attention / custom op boundary, which may introduce repeated device-to-host synchronization. For an N-layer encoder, this repeats the same max_seqlen computation and scalar conversion path in every layer.
What changed
Compute max_seqlen once in the model/encoder forward path when Flash Attention is requested.
Compute max_window_seqlen once for sliding-window variants.
Pass the precomputed values down through blocks into attention modules.
Keep the attention-level fallback without .item() for standalone attention calls, preserving previous behavior when max_seqlen is not provided.
Guard the computation behind is_flash_attention_requested, so non-Flash-Attention paths do not pay this cost.
Related
This is related to #44962 and the discussion in #44973. Instead of adding scalar conversion inside each attention layer, this PR lifts the computation to the model/encoder level where cu_seqlens is already available, reducing repeated per-layer work and avoiding repeated per-layer scalar conversions in the normal model forward path.
Experiment
Before
After
Code Agent Policy
The Transformers repo is currently being overwhelmed by a large number of PRs and issue comments written by
code agents. We are currently bottlenecked by our ability to review and respond to them. As a result,
we ask that new users do not submit pure code agent PRs at this time.
You may use code agents in drafting or to help you diagnose issues. We'd also ask autonomous "OpenClaw"-like agents
not to open any PRs or issues for the moment.
PRs that appear to be fully agent-written will probably be closed without review, and we may block users who do this
repeatedly or maliciously.
This is a rapidly-evolving situation that's causing significant shockwaves in the open-source community. As a result,
this policy is likely to be updated regularly in the near future. For more information, please read
CONTRIBUTING.md.Before submitting
Pull Request checks?
to it if that's the case.
Who can review?
cc @vasqu