Skip to content

Let continuous batching page the call, not the model - #48735

Closed
qgallouedec wants to merge 7 commits into
mainfrom
cb-attn-per-call
Closed

qgallouedec wants to merge 7 commits into
mainfrom
cb-attn-per-call

Conversation

@qgallouedec

@qgallouedec qgallouedec commented Sep 12, 2026 •

Copy link
Copy Markdown
Member

CPU CI GPU run-slow

What

init_continuous_batching puts paged|<impl> on the model and only stop() takes it off. So while the engine is alive, any ordinary forward on that model lands in the paged kernel with no paged cache:

manager = model.init_continuous_batching(generation_config=cfg)
manager.start()
model(input_ids=batch, labels=batch).loss.backward()
# ValueError: `paged|sdpa` was called without a paged attention cache
# or TypeError: paged_attention_forward() missing 6 required positional arguments

That blocks training a model while generating from it, which is the whole point of the pause mechanism in #48462 (finding 1 of my review there).

The change

Paged-ness is a property of the call, not of the model. The engine already passes its cache and all the packed-input metadata (cu_seq_lens_q/k, read_index, write_index, block_table) through the forward kwargs. So flash_attention_forward and sdpa_attention_forward route to their paged counterpart when the call carries a PagedAttentionCache, and stay ordinary otherwise:

if is_paged_call(kwargs):
    from .sdpa_paged import sdpa_attention_paged_forward
    return sdpa_attention_paged_forward(...)

The model then stays on the implementation it was loaded with and serves both paths. switch_to_cb_friendly_attn still upgrades to flash when available, it just no longer rewrites the implementation string.

eager keeps the paged| prefix: 334 modeling files define their own eager_attention_forward and "eager" is never registered in ALL_ATTENTION_FUNCTIONS, so there is no single forward to route.

The mask needed no change. Flash builds its own from the cumulative seqlens, and for eager/sdpa the engine's 4D mask hits the early return at the top of _preprocess_mask_arguments, so create_causal_mask never runs.

This is the direction that #48297 tried in a19f0d7 and dropped on review, flipped: the fallback there was paged -> plain when no cache, here it is plain -> paged when there is one.

Measured

Qwen2.5-0.5B, H100, generate 3 prompts / training forward+backward / generate again, all with the manager running:

attn compile before after
flash_attention_3 0 TypeError loss 6.9144, stays flash
flash_attention_3 1 TypeError loss 6.9144, stays flash
sdpa 0 ValueError loss 6.9410, stays sdpa
sdpa 1 ValueError loss 6.9410, stays sdpa

Completions before the training step are byte-identical to main.

`init_continuous_batching` put `paged|<impl>` on the model and only `stop()`
took it off, so any ordinary forward while the engine ran landed in the paged
kernel with no paged cache and raised.

The engine already passes its cache and the packed-input metadata through the
forward kwargs, so `flash_attention_forward` and `sdpa_attention_forward` can
route to their paged counterpart when a call carries a `PagedAttentionCache`
and stay ordinary otherwise. The model is then left on the implementation it
was loaded with and serves both.

`eager` keeps the `paged|` prefix: every model defines its own eager forward,
so there is no single one to route.

`attn_mask_is_needed` now keys off the implementation instead of the `paged|`
prefix, which only eager still carries.
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

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.

Comment on lines +93 to +97
# Dispatch to paged attention instead if there is a paged cache
if isinstance(kwargs.get("cache"), PagedAttentionCache):
return sdpa_attention_paged_forward(
module, query, key, value, attention_mask, dropout=dropout, scaling=scaling, **kwargs
)

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.

Do we really still need those redirects then? it just goes to the same issue but on a different condition as previously no?

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.

I don't think this can lead to any issues in the current form!

The root of the issue is that the attn_implementation flavour (paged vs. unpaged) is tied to the model. While I agree that the implementation itself (sdpa, etc) needs to be tied, the fact that it is paged or not has no reason to be. If there is a paged cache -> use paged fllavour. Otherwise -> don't.

Previously the fallback was awkward: fall back from paged to normal, without a set attention mask or cu_seqlen. Now, it's more logical, and I don't see how it could fail. Open to ideas tho!

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.

But this is still weird, why would the ordinary forward call carry a cache at all? What would happen if we did use_cache=False in the forward call

My concern is that there is again no clean separation, attention variations should not call between each other

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The ordinary forward never carries a paged cache. It carries a DynamicCache, or nothing with use_cache=False, and both take the plain path: the branch is isinstance(cache, PagedAttentionCache), and only the engine builds one of those. The test's train step runs with the default cache; use_cache=False means no cache kwarg at all, so it can't reach the branch.

On separation: the paged kernel cannot run without the paged cache's block table, so keying on that cache is keying on the one thing that decides the kernel. The alternative, an implementation switch per call, is a write to config._attn_implementation, which I don't think is a nice design.

If two dispatch sites is the objection, I can move it to one: AttentionInterface.__getitem__ returning a wrapper that routes sdpa/flash on the cache type, so the attention functions themselves stay untouched?

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.

I discussed with @remi-or on slack but essentially what we want to do here is to fuse paged| with their counterparts (e.g. eager is not working this current way), e.g. because switching between the internal property is not thread safe to switch just in between

This is a bandaid solution and it's essentially better to do this properly than to rush it now.

@github-actions

Copy link
Copy Markdown
Contributor

CI recap

Dashboard: View test results in Grafana
Latest run: 35304592151:1
Result: success | Jobs: 16 | Tests: 190,606 | Failures: 0 | Duration: 15h 15m

@qgallouedec

Copy link
Copy Markdown
Member Author

Closing in favour of @remi-or's stack starting at #49069, which removes the paged| split properly instead of routing around it. thks @vasqu @remi-or

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.

4 participants