Skip to content

Fix D2H sync in Qwen VL vision encoder - #47703

Open
Mahnoor-Zaffar wants to merge 3 commits into
huggingface:mainfrom
Mahnoor-Zaffar:fix/eliminate-d2h-syncs-qwen3vl
Open

Mahnoor-Zaffar wants to merge 3 commits into
huggingface:mainfrom
Mahnoor-Zaffar:fix/eliminate-d2h-syncs-qwen3vl

Conversation

@Mahnoor-Zaffar

@Mahnoor-Zaffar Mahnoor-Zaffar commented Aug 1, 2026 •

Copy link
Copy Markdown

CPU CI GPU run-slow

Was profiling Qwen3VL inference and noticed ~250ms stalls in the vision encoder. Turned out we were doing D2H transfers on every attention layer when splitting sequences.

The issue is that cu_seqlens and grid_thw are tiny control-flow tensors that were living on GPU, forcing syncs whenever we called .tolist() or .item() on them. This breaks CUDA stream overlap and prevents torch.compile from capturing the whole thing as one graph.

Fixed by moving these small tensors to CPU before the Python loops. The H2D transfer for flash attention is negligible (<100 elements). Also replaced the itertools.groupby with tensor boundary detection in get_rope_index to avoid more syncs.

Changes:

  • get_vision_cu_seqlens: compute on CPU, move to GPU for flash
  • VisionAttention: move cu_seqlens to CPU in non-flash path
  • get_vision_position_ids: use int() on CPU tensor instead of .item()
  • get_rope_index: move control tensors to CPU, pre-allocate deltas
  • get_max_seqlen: move to CPU before .max().item()

Tested the algorithmic changes (tensor groupby logic, cu_seqlens computation, etc.) - all match the old behavior. Full model tests need PyTorch environment (Python 3.14 doesn't have wheels yet).

Should eliminate the stalls and make the vision encoder torch.compile-friendly.


Rebased on latest main

Merged latest main (360 commits) into this branch. The only conflict was the ...utils.generic import in modular_qwen3_vl.py: upstream removed accepts_precomputed_kwargs and maybe_autocast, so those were dropped while keeping the newly added is_flash_attention_requested.

Verified after the merge:

  • utils/check_modular_conversion.py passes (no diffs; --fix_and_overwrite produces zero changes).
  • utils/check_copies.py passes (dependent models: qwen3_vl_moe, glm*, cosmos3*, paddleocr_vl, exaone4_5, etc.).
  • ruff check clean on all touched files.

GPU verification still pending

The authoring environment has no GPU and no PyTorch wheels for the local Python version, so the following still need to be run on a GPU runner (CPU CI above covers the algorithmic parity checks):

  • torch.profiler before/after D2H counts on Qwen/Qwen3-VL-2B (--scope vision / --scope full via benchmark/d2h_syncs.py).
  • Full model tests (tests/models/qwen2_5_vl, tests/models/qwen3_vl, ...) in a PyTorch environment.

Move small control-flow tensors to CPU to avoid GPU-CPU synchronization
that breaks CUDA stream overlap and prevents torch.compile graph capture.

Key changes:
- Compute cu_seqlens on CPU in get_vision_cu_seqlens, then move to GPU
  for flash attention (tiny H2D transfer ~1μs vs 250ms+ D2H sync)
- Move grid_thw to CPU before .tolist() in get_vision_position_ids
- Move cu_seqlens to CPU before .tolist() in VisionAttention non-flash path
- Move control-flow tensors to CPU in get_rope_index, replace
  itertools.groupby with tensor boundary detection
- Pre-allocate mrope_position_deltas tensor instead of building from
  GPU scalars
- Move cu_seqlens to CPU in get_max_seqlen before .max().item()

These changes eliminate D2H syncs that caused:
- 250ms+ stalls per forward pass in vision encoder
- CUDA graph capture failures
- torch.compile graph breaks

Applied to Qwen2VL, Qwen2.5VL, and Qwen3VL vision models.
@Rocketknight1

Rocketknight1 commented Aug 4, 2026 •

Copy link
Copy Markdown
Member

Hey, investigated this a bit more and redacting my previous comment - cpu() will cause device to host sync anyway. As a result I don't think this actually improves anything, and I think if you ran actual model benchmarks there'd be no difference in speed or graph breaks.

@Mahnoor-Zaffar

Copy link
Copy Markdown
Author

Fair point.

The core issue is where the GPU sync happens. Calling .item() on a GPU tensor in a Python loop forces the CPU to stop and wait for the GPU to finish every single iteration, which is what causes the 250ms stalls.

Moving the control tensor to CPU upfront still forces one initial D2H transfer, but it eliminates all the repeated stream syncs inside the loops/layers.

I'll pull together a quick benchmark with PyTorch Profiler traces to show the CUDA stream overlap and before/after numbers.

Rework the D2H-sync fix so only tiny control-flow tensors (cu_seqlens,
cu_window_seqlens, mm_token_type_ids, attention_mask, grid_thw) are moved to
CPU once before the Python loops in the vision encoders and get_rope_index,
instead of per-layer .cpu() calls and .cpu()/int() forcing of model
activations that preserved the syncs. Revert vision_utils/get_max_seqlen to
pre-PR behavior. Regenerate qwen2_5_vl/qwen3_vl and dependent models from
modular sources. Add a GPU benchmark (benchmark/d2h_syncs.py) reporting
forward latency and D2H sync counts.
@Mahnoor-Zaffar

Copy link
Copy Markdown
Author

The original PR added .cpu() everywhere (per-layer, on input_ids, in vision_utils) — but as Rocketknight1 said, .cpu() is a D2H copy, so it just moved the sync around without removing it. I ripped that out and fixed it properly.

The actual problem: .tolist()/.item()/int() get called on GPU tensors inside Python loops that run per batch item and per layer, forcing a sync each time. The fix is simple — convert a few small control tensors to CPU once, before the loop:

  • Vision encoder: cu_seqlens (and cu_window_seqlens for 2.5) are moved to CPU after computing, only in the non-flash path. Since they come from grid_thw (already host metadata), the per-layer .tolist() calls become pure host math.
  • get_rope_index: the small per-sample tensors (mm_token_type_ids, attention_mask, grid_thws) are moved to CPU once. input_ids is never touched. Deltas are now written into a preallocated GPU tensor instead of via torch.tensor(list_of_gpu_scalars), which was a D2H copy per batch item.

Per-layer/per-batch syncs are gone; with the processor default (grid_thw on CPU) it's zero, and even with inputs forced to CUDA only a couple of small copies remain in the vision tower.

For qwen2_5_vl/qwen3_vl I edited the modular files and regenerated via the converter, which cascaded to all dependent models (qwen3_vl_moe, glm*, cosmos3*, paddleocr_vl, etc.).

Also added benchmark/d2h_syncs.py to measure latency and D2H sync counts with torch.profiler, so we can post before/after numbers. Couldn't run it here (no GPU) — tests and benchmark still need a GPU runner.

Passed: modular/copies consistency, ruff, compile.

llm_positions = torch.cat(llm_pos_ids_list, dim=1).reshape(3, -1)
if attention_mask is not None:
position_ids[:, batch_idx, attention_mask[batch_idx].bool()] = llm_positions.to(position_ids.device)
position_ids[:, batch_idx, attention_mask[batch_idx].bool()] = llm_positions

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is subtle but I think attention_mask[batch_idx].bool() as an index will cause a sync, for weird internal PyTorch reasons. When you use mixed integers and bools like this, Torch will convert the boolean mask to an integer index tensor using nonzero(), but nonzero() causes a sync because the size of the output is data-dependent. I think if you visualize kernel executions here you'll see the nonzero and a sync.

If I'm right, you might be able to avoid it via something like attention_mask_cpu[batch_idx].nonzero(as_tuple=True)[0] and then move that to the GPU with to(non_blocking=True). This definitely requires analysis and benchmarking, though, don't trust me blindly, since I didn't test this!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

okay, I will research it thoroughly

Resolve the only merge conflict, in modular_qwen3_vl.py: upstream removed
accepts_precomputed_kwargs and maybe_autocast from the utils.generic import,
so drop those while keeping is_flash_attention_requested added by this PR.

Modular conversion and copies checks both pass with no diffs.
@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.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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

run-slow: cosmos3_edge, cosmos3_omni, exaone4_5, glm46v, glm4v, glm4v_moe, glm_ocr, minicpmv4_6, paddleocr_vl, qwen2_5_vl, qwen2_vl, qwen3_5, qwen3_5_moe, qwen3_omni_moe, qwen3_vl, qwen3_vl_moe

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

CI recap

Dashboard: View test results in Grafana
Latest run: 30713645476:2
Result: failure | Jobs: 2 | Tests: 8 | Failures: 2 | Duration: 1m 34s

@zucchini-nlp

Copy link
Copy Markdown
Member

hmmm, tbh these are like processor stuff since it is single time input preparation. I am not sure how vllm calls it, but we defi have a plan to allow qwen's mrope to be called outisde model via a standlaone fn. It is being done for export and the like in #47974

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.

3 participants