| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Unit tests for canonicalize_singleton_dim_strides. |
| 4 | |
| 5 | Background |
| 6 | ---------- |
| 7 | When num_kv_heads_per_rank == 1 (e.g. Qwen3.5-397B with TP=8 → 1 KV head |
| 8 | per rank), PyTorch's is_contiguous() returns True for *any* stride on the |
| 9 | size-1 dimension. The KV cache allocator can therefore produce a tensor |
| 10 | where that singleton dim has stride = 1 element (2 bytes for bf16) instead |
| 11 | of the canonical product-of-remaining-dims value. |
| 12 | |
| 13 | CUDA TMA (used by FlashInfer XQA SM90 and Flash-Attention 3/4 on H100+) |
| 14 | requires all non-outermost strides to be multiples of 16 bytes. A 2-byte |
| 15 | stride triggers cudaErrorIllegalInstruction. |
| 16 | |
| 17 | canonicalize_singleton_dim_strides() patches degenerate strides on all |
| 18 | size-1 dimensions via torch.as_strided — zero-copy. |
| 19 | |
| 20 | The degenerate stride manifests at different positions in different backends: |
| 21 | - FlashInfer: stride(-3) after kv_cache.permute() → shape [..., 1, B, D] |
| 22 | - FlashAttention: stride(-2) after kv_cache.unbind(1) → shape [N, B, 1, D] |
| 23 | """ |
| 24 | |
| 25 | import torch |
| 26 | |
| 27 | from vllm.utils.torch_utils import canonicalize_singleton_dim_strides |
| 28 | |
| 29 | # --------------------------------------------------------------------------- |
| 30 | # Helpers |
| 31 | # --------------------------------------------------------------------------- |
| 32 | |
| 33 | |
| 34 | def _inject_degenerate_stride(t: torch.Tensor, dim: int) -> torch.Tensor: |
| 35 | """Return a view of t with a degenerate (stride=1) on a size-1 dim.""" |
| 36 | assert t.shape[dim] == 1, f"dim {dim} must have size 1" |
| 37 | strides = list(t.stride()) |
| 38 | strides[dim] = 1 # inject the bug |
| 39 | return t.as_strided(t.shape, strides) |
| 40 | |
| 41 | |
| 42 | # --------------------------------------------------------------------------- |
| 43 | # Tests: canonicalize_singleton_dim_strides |
| 44 | # --------------------------------------------------------------------------- |
| 45 | |
| 46 | |
| 47 | class TestCanonicalizeSingletonDimStrides: |
| 48 | def test_flashinfer_layout_dim_neg3(self): |
| 49 | """FlashInfer path: degenerate stride at dim -3 (num_kv_heads).""" |
| 50 | # Shape after permute: [num_blocks, 2, num_kv_heads, block_size, head_size] |
| 51 | num_blocks, block_size, head_size = 64, 16, 128 |
| 52 | t = torch.zeros(num_blocks, 2, 1, block_size, head_size, dtype=torch.bfloat16) |
| 53 | t_deg = _inject_degenerate_stride(t, dim=-3) |
| 54 | |
| 55 | assert t_deg.stride(-3) == 1 # confirm degenerate |
| 56 | assert t_deg.is_contiguous() # PyTorch doesn't notice |
| 57 | |
| 58 | fixed = canonicalize_singleton_dim_strides(t_deg) |
| 59 | |
| 60 | assert fixed.stride(-3) == block_size * head_size # canonical = 2048 |
| 61 | assert fixed.stride(-2) == head_size # inner dims unchanged |
| 62 | assert fixed.stride(-1) == 1 |
| 63 | |
| 64 | def test_flash_attn_layout_dim_neg2(self): |
| 65 | """FlashAttention path: degenerate stride at dim -2 (num_kv_heads).""" |
| 66 | # Shape after unbind(0): [num_blocks, block_size, num_kv_heads, head_size] |
| 67 | num_blocks, block_size, head_size = 64, 16, 128 |
| 68 | t = torch.zeros(num_blocks, block_size, 1, head_size, dtype=torch.bfloat16) |
| 69 | t_deg = _inject_degenerate_stride(t, dim=-2) |
| 70 | |
| 71 | assert t_deg.stride(-2) == 1 |
| 72 | assert t_deg.is_contiguous() |
| 73 | |
| 74 | fixed = canonicalize_singleton_dim_strides(t_deg) |
| 75 | |
| 76 | assert fixed.stride(-2) == head_size # canonical = 128 |
| 77 | assert fixed.stride(-1) == 1 |
| 78 | |
| 79 | def test_canonical_strides_returned_as_is(self): |
| 80 | """No degenerate strides → same object returned (no copy, no new view).""" |
| 81 | t = torch.zeros(64, 2, 1, 16, 128, dtype=torch.bfloat16) |
| 82 | result = canonicalize_singleton_dim_strides(t) |
| 83 | assert result is t |
| 84 | |
| 85 | def test_multi_kv_heads_unchanged(self): |
| 86 | """num_kv_heads > 1 → strides are already canonical → unchanged.""" |
| 87 | t = torch.zeros(16, 2, 4, 16, 128, dtype=torch.bfloat16) |
| 88 | original_strides = t.stride() |
| 89 | result = canonicalize_singleton_dim_strides(t) |
| 90 | assert result.stride() == original_strides |
| 91 | |
| 92 | def test_data_pointer_preserved(self): |
| 93 | """Fix is zero-copy: same underlying storage.""" |
| 94 | t = torch.zeros(8, 2, 1, 16, 128, dtype=torch.bfloat16) |
| 95 | t_deg = _inject_degenerate_stride(t, dim=-3) |
| 96 | fixed = canonicalize_singleton_dim_strides(t_deg) |
| 97 | assert fixed.data_ptr() == t_deg.data_ptr() |
| 98 | assert fixed.storage_offset() == t_deg.storage_offset() |
| 99 | |
| 100 | def test_multiple_singleton_dims(self): |
| 101 | """All size-1 dims with degenerate strides are fixed.""" |
| 102 | # Shape: [1, 1, 8, 32] — two size-1 dims |
| 103 | t = torch.zeros(1, 1, 8, 32, dtype=torch.float16) |
| 104 | # Both size-1 dims get degenerate strides |
| 105 | t_deg = t.as_strided(t.shape, (1, 1, 32, 1)) # both leading dims = 1 |
| 106 | |
| 107 | fixed = canonicalize_singleton_dim_strides(t_deg) |
| 108 | |
| 109 | assert fixed.stride(0) == 1 * 8 * 32 # canonical: 256 |
| 110 | assert fixed.stride(1) == 1 * 8 * 32 # canonical: 256 (same since size-1) |
| 111 | assert fixed.stride(2) == 32 |
| 112 | assert fixed.stride(3) == 1 |
| 113 | |
| 114 | def test_various_shapes_flashinfer(self): |
| 115 | """Correctness across different block_size / head_size for FlashInfer layout.""" |
| 116 | for block_size, head_size in [(16, 64), (16, 128), (32, 128), (16, 256)]: |
| 117 | t = torch.zeros(8, 2, 1, block_size, head_size, dtype=torch.bfloat16) |
| 118 | t_deg = _inject_degenerate_stride(t, dim=-3) |
| 119 | fixed = canonicalize_singleton_dim_strides(t_deg) |
| 120 | assert fixed.stride(-3) == block_size * head_size, ( |
| 121 | f"Failed for block_size={block_size}, head_size={head_size}: " |
| 122 | f"got stride(-3)={fixed.stride(-3)}" |
| 123 | ) |
| 124 | |
| 125 | def test_various_shapes_flash_attn(self): |
| 126 | """Correctness across different shapes for FlashAttention layout.""" |
| 127 | for block_size, head_size in [(16, 64), (16, 128), (32, 128)]: |
| 128 | t = torch.zeros(8, block_size, 1, head_size, dtype=torch.bfloat16) |
| 129 | t_deg = _inject_degenerate_stride(t, dim=-2) |
| 130 | fixed = canonicalize_singleton_dim_strides(t_deg) |
| 131 | assert fixed.stride(-2) == head_size, ( |
| 132 | f"Failed for block_size={block_size}, head_size={head_size}: " |
| 133 | f"got stride(-2)={fixed.stride(-2)}" |
| 134 | ) |
| 135 | |
| 136 | def test_tma_alignment_satisfied_after_fix_bf16(self): |
| 137 | """After fix, all strides meet 16-byte TMA alignment for bf16.""" |
| 138 | t = torch.zeros(64, 2, 1, 16, 128, dtype=torch.bfloat16) |
| 139 | t_deg = _inject_degenerate_stride(t, dim=-3) |
| 140 | fixed = canonicalize_singleton_dim_strides(t_deg) |
| 141 | |
| 142 | element_size = fixed.element_size() # 2 bytes for bf16 |
| 143 | for i, s in enumerate(fixed.stride()): |
| 144 | assert (s * element_size) % 16 == 0 or i == len(fixed.stride()) - 1, ( |
| 145 | f"dim {i} stride {s} * {element_size} bytes not 16-byte aligned" |
| 146 | ) |
| 147 | |
| 148 | def test_non_contiguous_outer_dims_preserved(self): |
| 149 | """Outer (non-size-1) non-contiguous strides are left unchanged.""" |
| 150 | # Simulate cross-layer unified allocation: num_blocks stride is non-canonical |
| 151 | # but the inner dims should be fixed. |
| 152 | base = torch.zeros(200, 2, 1, 16, 128, dtype=torch.bfloat16) |
| 153 | # Slice every 2nd block → non-canonical outer stride |
| 154 | t_sliced = base[::2] # shape [100, 2, 1, 16, 128], stride[0] = 2*canonical |
| 155 | t_deg = _inject_degenerate_stride(t_sliced, dim=-3) |
| 156 | |
| 157 | fixed = canonicalize_singleton_dim_strides(t_deg) |
| 158 | |
| 159 | # Outer stride should be unchanged (not a size-1 dim) |
| 160 | assert fixed.stride(0) == t_sliced.stride(0) |
| 161 | # Inner degenerate stride should be fixed |
| 162 | assert fixed.stride(-3) == 16 * 128 |