|
| 1 | +"""Host-side validation tests for paged-KV configurations. |
| 2 | +
|
| 3 | +These tests do NOT require a GPU. They exercise the early input validation in |
| 4 | +``_flash_attn_fwd`` that rejects paged-KV configs whose ``n_block_size`` (tile_n) |
| 5 | +is not divisible by the cp.async KV-load thread count. Such configs would |
| 6 | +otherwise fail with an opaque error deep inside JIT compilation (or, worse, |
| 7 | +silently drop KV rows). |
| 8 | +
|
| 9 | +The pattern mirrors the fast two-pass test workflow: we run under |
| 10 | +``FakeTensorMode`` (no GPU memory) and replace ``cute.compile`` with a sentinel |
| 11 | +so that reaching compilation is observable without a CUDA device. The kernel |
| 12 | +arch is forced via the private ``_arch`` argument so the selected forward path |
| 13 | +is deterministic on any host. |
| 14 | +""" |
| 15 | + |
| 16 | +import pytest |
| 17 | + |
| 18 | +# CUDA / CuTeDSL are Linux+NVIDIA only; skip cleanly where they are unavailable |
| 19 | +# (e.g. macOS dev hosts) instead of erroring at collection time. |
| 20 | +torch = pytest.importorskip("torch") |
| 21 | +pytest.importorskip("cutlass") |
| 22 | + |
| 23 | +from torch._subclasses.fake_tensor import FakeTensorMode # noqa: E402 |
| 24 | + |
| 25 | +from flash_attn.cute import interface # noqa: E402 |
| 26 | +from flash_attn.cute.interface import _flash_attn_fwd # noqa: E402 |
| 27 | + |
| 28 | + |
| 29 | +class _CompileReached(Exception): |
| 30 | + """Raised by the stubbed ``cute.compile`` to mark that host validation passed.""" |
| 31 | + |
| 32 | + |
| 33 | +def _install_compile_sentinel(monkeypatch): |
| 34 | + """Replace cute.compile with a sentinel and reset the compile cache. |
| 35 | +
|
| 36 | + If host validation passes, control reaches ``cute.compile`` and the sentinel |
| 37 | + fires; we treat that as "the config was accepted". |
| 38 | + """ |
| 39 | + |
| 40 | + def _sentinel(*args, **kwargs): |
| 41 | + raise _CompileReached |
| 42 | + |
| 43 | + monkeypatch.setattr(interface.cute, "compile", _sentinel) |
| 44 | + # Ensure we never short-circuit on a previously cached kernel. |
| 45 | + monkeypatch.setattr(_flash_attn_fwd, "compile_cache", {}) |
| 46 | + |
| 47 | + |
| 48 | +def _make_paged_inputs( |
| 49 | + *, |
| 50 | + batch_size=1, |
| 51 | + seqlen_q=128, |
| 52 | + num_head=4, |
| 53 | + num_head_kv=4, |
| 54 | + head_dim=128, |
| 55 | + head_dim_v=128, |
| 56 | + num_pages=4, |
| 57 | + page_size=64, |
| 58 | + max_num_pages_per_seq=4, |
| 59 | + dtype=torch.bfloat16, |
| 60 | +): |
| 61 | + """Build fake (meta) tensors for a paged-KV forward call.""" |
| 62 | + q = torch.empty(batch_size, seqlen_q, num_head, head_dim, dtype=dtype) |
| 63 | + k = torch.empty(num_pages, page_size, num_head_kv, head_dim, dtype=dtype) |
| 64 | + v = torch.empty(num_pages, page_size, num_head_kv, head_dim_v, dtype=dtype) |
| 65 | + page_table = torch.zeros(batch_size, max_num_pages_per_seq, dtype=torch.int32) |
| 66 | + return q, k, v, page_table |
| 67 | + |
| 68 | + |
| 69 | +@pytest.mark.parametrize("arch", [90, 100]) |
| 70 | +def test_paged_kv_indivisible_tile_n_raises(monkeypatch, arch): |
| 71 | + """tile_n not divisible by the KV-load thread count must raise ValueError. |
| 72 | +
|
| 73 | + tile_n is forced to 96 via ``tile_mn``. On SM90 the cp.async loader uses 128 |
| 74 | + threads; on SM100 (q_stage == 1 here, since seqlen_q == tile_m) it also uses |
| 75 | + 128. 96 % 128 != 0, so the config is rejected before compilation. |
| 76 | + """ |
| 77 | + _install_compile_sentinel(monkeypatch) |
| 78 | + with FakeTensorMode(): |
| 79 | + q, k, v, page_table = _make_paged_inputs(page_size=64) |
| 80 | + with pytest.raises(ValueError, match=r"cp\.async KV-load path"): |
| 81 | + _flash_attn_fwd( |
| 82 | + q, |
| 83 | + k, |
| 84 | + v, |
| 85 | + page_table=page_table, |
| 86 | + tile_mn=(128, 96), # force n_block_size = 96 |
| 87 | + _arch=arch, |
| 88 | + ) |
| 89 | + |
| 90 | + |
| 91 | +@pytest.mark.parametrize("arch", [90, 100]) |
| 92 | +def test_paged_kv_divisible_tile_n_passes_guard(monkeypatch, arch): |
| 93 | + """A valid paged config (tile_n divisible by load threads) clears the guard. |
| 94 | +
|
| 95 | + tile_n is forced to 128 (divisible by both 128 and 64), with page_size=64 so |
| 96 | + the cp.async path is selected. The new validation must NOT raise; execution |
| 97 | + should reach the stubbed ``cute.compile`` (``_CompileReached``). |
| 98 | + """ |
| 99 | + _install_compile_sentinel(monkeypatch) |
| 100 | + with FakeTensorMode(): |
| 101 | + q, k, v, page_table = _make_paged_inputs(page_size=64) |
| 102 | + with pytest.raises(_CompileReached): |
| 103 | + _flash_attn_fwd( |
| 104 | + q, |
| 105 | + k, |
| 106 | + v, |
| 107 | + page_table=page_table, |
| 108 | + tile_mn=(128, 128), # divisible by the KV-load thread count |
| 109 | + _arch=arch, |
| 110 | + ) |
| 111 | + |
| 112 | + |
| 113 | +def test_paged_kv_tma_path_skips_guard(monkeypatch): |
| 114 | + """When page_size == tile_n the TMA path is used and the guard is bypassed. |
| 115 | +
|
| 116 | + Even with tile_n=96 (not divisible by 128), page_size == tile_n means |
| 117 | + PagedKVManager's cp.async loader is never used, so the config is accepted and |
| 118 | + reaches compilation. |
| 119 | + """ |
| 120 | + _install_compile_sentinel(monkeypatch) |
| 121 | + with FakeTensorMode(): |
| 122 | + q, k, v, page_table = _make_paged_inputs(page_size=96, num_pages=4) |
| 123 | + with pytest.raises(_CompileReached): |
| 124 | + _flash_attn_fwd( |
| 125 | + q, |
| 126 | + k, |
| 127 | + v, |
| 128 | + page_table=page_table, |
| 129 | + tile_mn=(128, 96), # page_size == tile_n -> TMA path |
| 130 | + _arch=90, |
| 131 | + ) |
0 commit comments