Adding PagedAttention support for CausalLM models - #1209
Conversation
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
…ormat cleanup Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
|
"Limitation - Cache writes to only 1 block at a time per batch element => CPL is less than or equal to kv_block_size. Hence, cache writes should not cross the block boundary." Where is this enforced in the code? |
| batch, seq_len = position_ids.shape | ||
| num_kv_blocks, num_kv_heads, block_size, dh = k_out.shape | ||
| ctx_indices = torch.arange(block_size)[None, ...] | ||
| block_fill_len = position_ids.max(1, keepdim=True).values % block_size |
There was a problem hiding this comment.
Why don't you use slot_id here?
position_id.max % block_size should be same as slot_id, isnt it?
write** function is operating on slot_ids
There was a problem hiding this comment.
This was switched from using (slot_id + seq_len) to (position_ids.max(1, keepdim=True).values % block_size) because of PyTorch and ONNX runtime generating NaN logits due to padding tokens even though it generated correct logits with QAIC compiled QPC run.
While reading blocks, we have 3 scenarios:
- Block is invalid => do no read this block => invalid index assigned => gather_limit is 0
- Block is fully valid => read the full block => gather_limit is kv_block_size
- Block is partially valid => only some entries/tokens written in current block => read the block till valid tokens only => gather_limit = valid tokens length.
Since VLLM assigns slot_id at the start of prefill/decode run, so at the start of prefill slot_id is 0. If we write/scatter CPL = 4 tokens in a block with kv_block_size = 8, then we must read/gather only 4 tokens which can be achieved using (slot_id + seq_len) for recently "updated" block. Now, for QAIC runs padding tokens are not counted in seq_len while for PyTorch/ ONNX runtime runs padding tokens are part of seq_len. This leads to reading more tokens than needed for PyTorch and ONNX runtime cases and ends up with NaN logits. Using (position_ids.max(1, keepdim=True).values % block_size) for gather limit avoids this issue for all cases.
| block_id = block_table[rows, block_index].unsqueeze(-1) | ||
| ctx_indices = torch.where(block_id < 0, invalid_scatter_index, ctx_indices) | ||
| block_id = block_id.unsqueeze(-1) | ||
| self.keys = CtxScatterFuncPagedAttention.apply(self.keys, block_id, ctx_indices, key_states) |
There was a problem hiding this comment.
On a CB decode step, key_states have shape [1, num_kv_heads, 1, dh] (single request) and block_table is [full_batch_size, num_kv_blocks]. The code therefore:
Reads block_table[torch.arange(1), block_index] — only row 0 of the block table, regardless of which request slot is active.
Scatters into self.keys at block_id = block_table[0, position_ids.max // block_size]
This means all continuously‑batched requests write into request‑0’s KV blocks.
Compare with mainline _QEffDynamicLayer.update() and _QEffDynamicLayer.write_only() in the same file. Both explicitly consume batch_index for the CB path. Paged write doesn't seem to do this
There was a problem hiding this comment.
Block_table has shape [batch_size, num_kv_blocks] => if key_states have shape [1, num_kv_heads, 1, dh] then block_table will have shape [1, num_kv_blocks]. Hence, in this case we need to read K/V cache from physical location pointed to by block_index'th entry in the block_table and block table has only 1 row. The accuracy of the block_table is ensured by VLLM.
In CB case, each batch_index does not have separate allocated KV cache space. E.g. if 2 requests in the continuous batch share the same prefix, then only one of them will write to cache (or both of them can write to same location with same data, this is VLLM level optimization) and VLLM will ensure that KV cache for both those requests be read from the same physical KV blocks.
Let's take an example of num_kv_blocks=4 and BS=2. Let's say prefill wrote to 1 block only, then in decode case the block_table will look like [[0,1,-1,-1], [2,3,-1,-1]] in case no prefix is shared where -1 denotes invalid block which has not been written to yet. If for the same case, we share a prefix, the block table will look like [[0,1,-1,-1], [0,2,-1,-1]]. So, there might be cases where we read the same block for 2 different requests or different blocks and all this is managed by VLLM before the block_table for the current request is sent.
To tie this back to your question, if batch_size is 1 and num_kv_blocks is 4, then for first request the block_table can be [0,1,2,17] and for second request the block_table can be [4,15,22,80]. With block_index 0, we will read KV cache at physical block 0 for request 1 and physical block 4 for request 2 even though we read row 0 for both. Similarly, scatter will happen at physical block pointed to by (position_ids.max // kv_block_size)th entry in the block_table for that particular request.
The key idea here is that block_table is not fixed and it updates with input_ids and position_ids updates.
| return compilation_batch_size, compilation_ctx_len, compilation_fbs | ||
| if compilation_num_kv_blocks := spec.get("num_kv_blocks", None): | ||
| compilation_num_kv_blocks = int(compilation_num_kv_blocks) | ||
| return compilation_batch_size, compilation_ctx_len, compilation_fbs, compilation_num_kv_blocks |
There was a problem hiding this comment.
Every caller in the codebase that unpacks 3 values will break with this change. You are updating only two call sites: cloud_ai_100_exec_kv and kv_offload_generate.
The remaining get_compilation_dims callers on the vision‑language single‑QPC generate paths and in some tests were not updated
There was a problem hiding this comment.
Thanks for catching this. I have updated QEfficient/generation/vlm_generation.py and QEfficient/transformers/models/modeling_auto.py files. The following file was unpacking only 2 values, have updated it as well: QEfficient/transformers/models/modeling_auto.py
I was expecting to catch such issues in tests.
| compilation_fbs = int(compilation_fbs) | ||
| return compilation_batch_size, compilation_ctx_len, compilation_fbs | ||
| if compilation_num_kv_blocks := spec.get("num_kv_blocks", None): | ||
| compilation_num_kv_blocks = int(compilation_num_kv_blocks) |
There was a problem hiding this comment.
what happens without blocking?
There was a problem hiding this comment.
Without kv_blocking, num_kv_blocks gets None value and none of the downstream "if num_kv_blocks ..." branches are triggered.
| fbs: int = constants.ONNX_EXPORT_EXAMPLE_FBS | ||
| num_kv_blocks = self.hash_params["blocking_kwargs"].num_kv_blocks | ||
| self.supports_paged_attention = "paged" in self.hash_params["blocking_kwargs"].mode | ||
| seq_len = kv_block_size = -(-seq_len // num_kv_blocks) if self.supports_paged_attention else seq_len |
There was a problem hiding this comment.
if paged attention is false, we don't want block_size to be seq_len
There was a problem hiding this comment.
kv_block_Size is not used anywhere if self.supports_paged_attention is False. Hence the fallback to default of kv_block_size = seq would not effect any non-pagedAttention path here. That fallback to default is to ensure seq_len = seq_len if self.supports_paged_attention is False.
Please let me know if you see any additional issues with this.
| return mode | ||
| if num_q_blocks > 1: | ||
| return "q" | ||
| return mode |
There was a problem hiding this comment.
_normalize_attention_mode can return kv_paged.
if num_q_blocks > 1 and num_kv_blocks == 1, this still returns "kv_paged"
Is that ok?
There was a problem hiding this comment.
Same as above. Corrected.
|
|
||
| def _resolve_effective_blocking_mode(attention_cfg: Dict[str, Any], requested_mode: str) -> str: | ||
| mode = _normalize_attention_mode(requested_mode) | ||
| if mode == "": |
There was a problem hiding this comment.
Why did you remove this?
If user didn't specify blocking, why should below code get executed?
There was a problem hiding this comment.
This has been corrected in the latest commit. Details in the comment above.
| @@ -208,6 +208,121 @@ def blocked_kv_attention_forward( | |||
| return attn_output, attn_weights | |||
|
|
|||
|
|
|||
There was a problem hiding this comment.
Six new attention forward functions are copy‑paste of the corresponding non‑paged ones.
The four paged functions differ only in the KV‑block fetch step (contiguous slice vs paged gather). Core functionality is identical. Instead of doubling the code, share a helper _fetch_kv_block(cache, layer_idx, j, num_kv_blocks, cache_kwargs, paged: bool) and keep one set of forwards
There was a problem hiding this comment.
This was done to keep the paged and non-paged paths completely isolated from each other while developing the PagedAttention implementation for the first time and also to allow different attention calculation optimizations for each path, if needed. This separation was also done to follow QEfficient infrastructure style where different forwards are picked for different blocking methods.
Once PagedAttention is reviewed, I can share helper functions to keep one set of forward.
There was a problem hiding this comment.
It would help if you can update this now. Will be easier to review
| "block_table": block_table, | ||
| "slot_id": slot_id, | ||
| } | ||
| if sliding_window is not None: |
There was a problem hiding this comment.
Why separate handling? are you assuming that block size will always be > SWA size?
There was a problem hiding this comment.
In the current implementation, PagedAttention hasn't been evaluated with sliding window attention yet. The reason is that in sliding window attention, different layers will require different max num_kv_blocks and how VLLM handles this heterogeneity hasn't been studied yet. This will be done in a future implementation.
There was a problem hiding this comment.
Better to assert the path which is not supported instead of letting it fall through
| if hasattr(self.model, "model"): | ||
| self.model.model.qaic_config = qaic_config | ||
| if hasattr(self.model.model, "model"): | ||
| self.model.model.model.qaic_config = qaic_config |
There was a problem hiding this comment.
Prefer passing qaic_config explicitly through constructors, or attach it once on the top wrapper and always read via a helper that walks up.
kdulla
left a comment
There was a problem hiding this comment.
The approach of adding new blocking modes adds a lot of repeated unnecessary code, changing that should help streamline many parts of this
| class BlockingMode(str, Enum): | ||
| NONE = "" | ||
| KV = "kv" | ||
| KV_PAGED = "kv_paged" |
There was a problem hiding this comment.
Would be better to not changed BlockingMode and instead add a paged_attention_flag to AttentionBlockingConfig
| return past_key_value is not None and hasattr(past_key_value, "read_only_pagedAttention") | ||
|
|
||
|
|
||
| _STRATEGIES: Dict[BlockingMode, Callable] = { |
There was a problem hiding this comment.
Can create a second dictionary _STRATEGIES_PAGED that is checked if AttentionBlockingConfig has paged_attention flag True
| head_block_size: Optional[int] = None | ||
| skip_kv: Optional[bool] = True | ||
| num_batch_blocks: Optional[int] = None | ||
|
|
| comp_ctx_lengths=comp_ctx_lengths, | ||
| batch_index=batch_index, | ||
| position_ids=position_ids, | ||
| block_table=block_table, |
There was a problem hiding this comment.
Is this needed if we are not doing paged attention in this else condition?
| sliding_window=sliding_window, | ||
| ) | ||
|
|
||
| strategy = _STRATEGIES.get(blocking_config.mode) |
There was a problem hiding this comment.
Can change this to check different strategies dictionary based on paged_attention flag or not
| def _normalize_attention_mode(raw_mode: str) -> str: | ||
| mode = raw_mode.lower() | ||
| if "h" in mode and "q" in mode and "kv" in mode: | ||
| if "h" in mode and "q" in mode and "kv" in mode and "paged" in mode: |
There was a problem hiding this comment.
See comments in attention_blocking, if we change paged_attention to a flag instead of additional BlockingModes we can avoid extra if conditions here
| ): | ||
| mode_from_config = "kv" + mode_from_config | ||
| blocking_config.num_kv_blocks = _get_valid_num_blocks(qaic_config, "num_kv_blocks") | ||
| if ( |
There was a problem hiding this comment.
Only kv_paged attention is possible from qaic_config from this code
| comp_ctx_lengths: Optional[torch.LongTensor] = None, | ||
| batch_index: Optional[torch.LongTensor] = None, | ||
| position_ids: Optional[torch.LongTensor] = None, | ||
| block_table: Optional[torch.LongTensor] = None, |
There was a problem hiding this comment.
We only ever call this function when use_paged_kv_blocked is disabled, is this needed?
| bs, num_kv_blocks | ||
| ) | ||
| example_inputs["slot_id"] = torch.zeros(bs, dtype=torch.int64) | ||
| dynamic_axes["block_table"] = {0: "batch_size", 1: "num_kv_blocks"} |
There was a problem hiding this comment.
Num kv blocks can't be a proper dynamic axis since it is fixed at export time based on the number of loops the attention forward goes through
| return "" | ||
|
|
||
|
|
||
| def _resolve_effective_blocking_mode(attention_cfg: Dict[str, Any], requested_mode: str) -> str: |
There was a problem hiding this comment.
When paged attention is not requested, why should blocking mode resolution differ from mainline?
| v_out = torch.where(invalid_mask.unsqueeze(-1), torch.tensor(0.0, dtype=torch.float32), v_out) | ||
| return k_out, v_out | ||
|
|
||
| def read_only_pagedAttention(self, block_index, updated, cache_kwargs): |
There was a problem hiding this comment.
Do we want mixed snake_case/camelCase cache APIs like read_only_pagedAttention?
| self.keys = CtxScatterFuncPagedAttention.apply(self.keys, block_id, ctx_indices, key_states) | ||
| self.values = CtxScatterFuncPagedAttention.apply(self.values, block_id, ctx_indices, value_states) | ||
|
|
||
| def get_seq_lengthPagedAttention(self, cache_position=None) -> int: |
There was a problem hiding this comment.
Should this return allocated paged-cache capacity or the active sequence length?
| """ | ||
|
|
||
| @staticmethod | ||
| def forward(data: torch.Tensor, block_index: torch.Tensor, position_ids: torch.Tensor, updates: torch.Tensor): |
There was a problem hiding this comment.
Is this in-place scatter safe if the same cache tensor is reused across eager parity checks?
| num_kv_blocks = self._get_num_kv_blocks() | ||
| kv_block_size = self._get_kv_block_size() | ||
|
|
||
| if self._is_paged_attention and num_kv_blocks and kv_block_size: |
There was a problem hiding this comment.
Is this intended to check the _is_paged_attention method object instead of calling it?
| @@ -112,6 +112,8 @@ def forward( | |||
| hidden_states: torch.Tensor, | |||
There was a problem hiding this comment.
Do we want every model wrapper signature to carry block_table and slot_id when most paths won't use paged attention?
| seq_len: int = constants.ONNX_EXPORT_EXAMPLE_SEQ_LEN | ||
|
|
||
| # increase seq_len if using a larger number of blocks | ||
| self.supports_paged_attention = False |
There was a problem hiding this comment.
Should export input construction mutate self.supports_paged_attention as model state?
| # increase seq_len if using a larger number of blocks and set PagedAttention params if required | ||
| if self.hash_params.get("blocking_kwargs", None): | ||
| max_blocks = -1 | ||
| for num_blocks in self.hash_params.get("blocking_kwargs").__dict__.values(): |
There was a problem hiding this comment.
Do we want max_blocks derived from every int in blocking_kwargs.dict, including unrelated fields?
| return past_key_value is not None and hasattr(past_key_value, "read_only_blockedKV") | ||
|
|
||
|
|
||
| def supports_paged_attention_blocked_kv(past_key_value: Optional[Cache]) -> bool: |
There was a problem hiding this comment.
Should paged behavior be selected by hasattr on the cache object rather than an explicit config signal?
This is not enforced in code right now since this is not a hard limitation. This limitation was introduced for current implementation based on the decision that CPL and kv_block_size should have same granularity. This is a "To do" for future implementation and probably the only one. If needed, a warning/status message can be added to alert the user of this limitation when blocking_mode="*kv_paged" and CPL > kv_block_size. |
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
Signed-off-by: Vaibhav Verma <vaibverm@qti.qualcomm.com>
|
Can you add tests for the cases below?
|
quic-hemagnih
left a comment
There was a problem hiding this comment.
- Add unit test cases for read_only_pagedAttention / write_only_pagedAttention on QEffLayerCache
Suggested Optimizations to reduce unnecessary loops-
- read_only_pagedAttention() is being called redundantly inside the head/q/batch loops, even though its inputs only depend on KV block index j.
For each j:
- block_index = block_table[:, j]
- updated = (position_ids.max(...) // kv_block_size) == j
- cache_kwargs is unchanged
These do not depend on head_block_idx, q_block_idx, or b_block_idx. As a result, the same expensive CtxGatherFuncPagedAttention / GatherND over the full KV cache is repeated multiple times, and only cheap head/batch slicing differs afterward.
For example:
- blocked_hqkv: calls = num_head_blocks × num_q_blocks × num_kv_blocks, but only num_kv_blocks unique gathers are needed.
- blocked_bhqkv: calls = num_head_blocks × num_q_blocks × num_batch_blocks × num_kv_blocks, but again only num_kv_blocks unique gathers are needed.
Suggest hoisting the gather outside the outer loops:
- Gather k/v once per KV block j.
- Cache the gathered k/v states.
- Reuse them inside head/q/batch loops and apply only the required slicing there.
This should reduce redundant CtxGatherFuncPagedAttention calls significantly, e.g. from 64 to 8 in hqkv and from 128 to 8 in bhqkv for the given configs, with the tradeoff of holding gathered KV blocks in memory temporarily.
- Same applies to blocked_bhqkv_paged_attention_forward also.
| num_kv_blocks = self._get_num_kv_blocks() | ||
| kv_block_size = self._get_kv_block_size() | ||
|
|
||
| if self._is_paged_attention and num_kv_blocks and kv_block_size: |
There was a problem hiding this comment.
Shouldn't it be self._is_paged_attention()? What's the intention here?
| "block_table": block_table, | ||
| "slot_id": slot_id, | ||
| } | ||
| if sliding_window is not None: |
There was a problem hiding this comment.
This peice of code will never execute, as at line#159, sliding _window should be none to enter in the code leg.
Now at this line you are checking that sliding window is not NONE.
| v_out = CtxGatherFuncPagedAttention.apply(v_out, block_indices, ctx_indices) | ||
|
|
||
| v_out = torch.where((invalid_mask.unsqueeze(1)).unsqueeze(-1), torch.tensor(0.0, dtype=torch.float32), v_out) | ||
| return k_out, v_out |
There was a problem hiding this comment.
Any reasons why k_out is not masked here? If we don't mask k_out then it might set a wrong current_max in the online softmax accumulator, which corrupts the numerical normalization of all subsequent valid blocks, producing a near-zero output instead of the correct attention-weighted value?
What do you think?
| v_out = torch.where((invalid_mask.unsqueeze(1)).unsqueeze(-1), torch.tensor(0.0, dtype=torch.float32), v_out) | ||
| return k_out, v_out | ||
|
|
||
| def write_only_pagedAttention(self, key_states, value_states, cache_kwargs): |
There was a problem hiding this comment.
Align function naming conventions with existing code of QEFF
| use_causal_mask = True | ||
| position_ids = cache_kwargs.get("position_ids") | ||
| block_table = cache_kwargs.get("block_table") # [BS, num_kv_blocks] -> each entry is block_id value | ||
| kv_block_size = past_key_value.get_seq_length() if past_key_value is not None else 0 |
There was a problem hiding this comment.
Why don't we use get_seq_lengthPagedAttention() in place of get_seq_length()?
For paged attention, keys has shape [total_num_kv_blocks, num_kv_heads, kv_block_size, dh], so keys.shape[-2] is kv_block_size — which happens to be
correct. But this is fragile as based on the paged layout. The newly added get_seq_lengthPagedAttention() method exists precisely for this purpose.
There was a problem hiding this comment.
This comment applies for other instances too
| return self.layers[layer_idx].read_only_blockedKV(start_index, end_index, cache_kwargs) | ||
|
|
||
| def read_only_pagedAttention(self, block_index, updated, layer_idx, cache_kwargs): | ||
| # def read_only_pagedAttention(self, start_index, end_index, layer_idx, cache_kwargs): |
This PR is a clone of #982. This PR adds the PagedAttention (https://arxiv.org/pdf/2309.06180) support for all CausalLM models in QEfficient.
The major change is that KV cache is not treated as a contiguous memory under this implementation but rather a collection of blocks which can reside in a non-contiguous fashion inside the memory. This forces cache scatter and gather operations to happen per KV block.
Summary of changes compared to BlockedKV:
4) a) block_id is each entry in the block_table and points to the physical K/V block that needs to be read/written corresponding to (position_id // kv_block_size)th entry in block_table. ‘-1’ signifies invalid/unallocated block.
4) b) slot_id denotes how many entries are already filled in currently active block => read up to / write after (slot_id – 1)