diff --git a/README.md b/README.md
index fd21d915..16947aeb 100644
--- a/README.md
+++ b/README.md
@@ -116,6 +116,7 @@ A more accessible, comprehensive, and efficient toolkit for large model compress
XAttention
FlashPrefill
VecAttention
+ CoSA
diff --git a/README_cn.md b/README_cn.md
index 75db95ac..c0d26b6c 100644
--- a/README_cn.md
+++ b/README_cn.md
@@ -116,6 +116,7 @@
XAttention
FlashPrefill
VecAttention
+ CoSA
diff --git a/angelslim/compressor/sparsity/algorithms/__init__.py b/angelslim/compressor/sparsity/algorithms/__init__.py
index 34df90fb..9c0fc550 100644
--- a/angelslim/compressor/sparsity/algorithms/__init__.py
+++ b/angelslim/compressor/sparsity/algorithms/__init__.py
@@ -31,7 +31,15 @@
# others — but the swallowed exception is LOGGED (WARNING) rather than silently
# dropped, so a genuine syntax/import bug surfaces its real cause here instead of
# a confusing "not registered" ValueError much later.
-for _name in ("stem", "minference", "flexprefill", "xattention", "flashprefill", "vecattention"):
+for _name in (
+ "stem",
+ "minference",
+ "flexprefill",
+ "xattention",
+ "flashprefill",
+ "vecattention",
+ "cosa",
+):
try:
importlib.import_module(f".{_name}", __name__)
except Exception as _e: # noqa: BLE001 pragma: no cover - defensive
diff --git a/angelslim/compressor/sparsity/algorithms/cosa/__init__.py b/angelslim/compressor/sparsity/algorithms/cosa/__init__.py
new file mode 100644
index 00000000..7be99d7b
--- /dev/null
+++ b/angelslim/compressor/sparsity/algorithms/cosa/__init__.py
@@ -0,0 +1,17 @@
+# Copyright 2025 Tencent Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .algorithm import CoSA # noqa: F401
+
+__all__ = ["CoSA"]
diff --git a/angelslim/compressor/sparsity/algorithms/cosa/algorithm.py b/angelslim/compressor/sparsity/algorithms/cosa/algorithm.py
new file mode 100644
index 00000000..9e2c248a
--- /dev/null
+++ b/angelslim/compressor/sparsity/algorithms/cosa/algorithm.py
@@ -0,0 +1,130 @@
+# Copyright 2025 Tencent Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""CoSA sparse-prefill algorithm.
+
+CoSA keeps, per query-block, the smallest set of causal key-blocks whose
+cumulative attention mass covers ``topp``. Mass is estimated from a
+``stride``-subsampled proxy of ``Q @ K^T`` (Triton, ``ops/proxy.py``). The
+selection is emitted as block ids **ordered by descending priority**; the
+``hpc`` kernel walks that order and skips the tail below ``skipsoftmax_threshold``.
+
+Hard constraints: ``triton`` + internal ``hpc``, bf16, head_dim 128,
+``block_size == 128``, ``batch == 1``. No pseudo-sparse fallback.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from ..._base import AlgorithmTraits, IncompatibleConfigError, SparsityAlgorithm
+from ...registry import SparsityAlgorithmRegistry
+
+if TYPE_CHECKING:
+ from transformers import PreTrainedModel
+
+_DEFAULT_STRIDE = 8
+_DEFAULT_TOPP = 0.8
+_DEFAULT_BLOCK_SIZE = 128
+# hpc anyorderskip: ``< 0`` disables threshold skip; ``>= 0`` enables.
+_DEFAULT_SKIPSOFTMAX_THRESHOLD = 1000
+
+
+@SparsityAlgorithmRegistry.register("cosa")
+class CoSA(SparsityAlgorithm):
+ """CoSA ordered block-sparse prefill (Qwen3 / Qwen3-MoE / Hunyuan-V3)."""
+
+ name = "cosa"
+
+ @property
+ def traits(self) -> AlgorithmTraits:
+ return AlgorithmTraits(
+ requires_unrepeated_kv=False, # proxy and kernel both see post-repeat K/V
+ needs_calibration=False,
+ supports_padding_mask=False, # block selection never sees the mask
+ model_modal="any",
+ compatible_model_types=frozenset({"qwen3", "qwen3_moe", "hy_v3"}),
+ )
+
+ def setup(self, model: "PreTrainedModel") -> None:
+ if self.allow_pseudo_sparse:
+ raise IncompatibleConfigError(
+ "CoSA has no pseudo-sparse reference path, so "
+ "allow_pseudo_sparse=true cannot be honoured. Remove it; the "
+ "proxy requires `triton` and the attention requires the `hpc` "
+ "extension unconditionally."
+ )
+
+ cfg = dict(self.attn_kwargs)
+ stride = int(cfg.get("stride", _DEFAULT_STRIDE))
+ if stride < 1:
+ raise IncompatibleConfigError(f"stride must be >= 1, got {stride}.")
+
+ chunk_size = cfg.get("chunk_size", None)
+ self._cfg = {
+ "stride": stride,
+ "topp": self._resolve_topp(cfg.get("topp", _DEFAULT_TOPP)),
+ "block_size": int(cfg.get("block_size", _DEFAULT_BLOCK_SIZE)),
+ "chunk_size": None if chunk_size is None else int(chunk_size),
+ "skipsoftmax_threshold": float(
+ cfg.get("skipsoftmax_threshold", _DEFAULT_SKIPSOFTMAX_THRESHOLD)
+ ),
+ }
+ self._per_instance_ready = True
+
+ @staticmethod
+ def _resolve_topp(topp) -> float:
+ """Validate ``topp`` as a single scalar in ``(0, 1]``."""
+ if isinstance(topp, bool) or not isinstance(topp, (int, float)):
+ raise IncompatibleConfigError(
+ f"topp must be a scalar float, got {type(topp).__name__}. "
+ f"Per-head topp tables are not supported."
+ )
+ topp = float(topp)
+ if not 0.0 < topp <= 1.0:
+ raise IncompatibleConfigError(
+ f"topp must be in (0, 1], got {topp}. It is a cumulative "
+ f"attention-mass coverage target; 1.0 keeps every causal block."
+ )
+ return topp
+
+ def build_attn_forward(self, attn_module, model: "PreTrainedModel"):
+ if not self._per_instance_ready:
+ raise IncompatibleConfigError(
+ "CoSA.setup(model) must be called before build_attn_forward."
+ )
+ from .._forward_templates import get_forward_template
+ from .._forward_templates._common import repeat_kv
+ from .prefill import cosa_prefill
+
+ cfg = self._cfg
+
+ def _prefill_fn(attn, query_states, key_states, value_states):
+ # The kernel consumes post-repeat K/V at num_attention_heads.
+ return cosa_prefill(
+ query_states,
+ repeat_kv(key_states, attn.num_key_value_groups),
+ repeat_kv(value_states, attn.num_key_value_groups),
+ stride=cfg["stride"],
+ topp=cfg["topp"],
+ block_size=cfg["block_size"],
+ chunk_size=cfg["chunk_size"],
+ skipsoftmax_threshold=cfg["skipsoftmax_threshold"],
+ )
+
+ model_type = getattr(model.config, "model_type", None)
+ template = get_forward_template(model_type)
+ attn_module.attn_forward_config = cfg
+ forward = template(_prefill_fn, supports_padding_mask=self.traits.supports_padding_mask)
+ return forward.__get__(attn_module, type(attn_module))
diff --git a/angelslim/compressor/sparsity/algorithms/cosa/ops/__init__.py b/angelslim/compressor/sparsity/algorithms/cosa/ops/__init__.py
new file mode 100644
index 00000000..c58d6430
--- /dev/null
+++ b/angelslim/compressor/sparsity/algorithms/cosa/ops/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2025 Tencent Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""CoSA ops: Triton selection proxy and hpc layout adapters."""
diff --git a/angelslim/compressor/sparsity/algorithms/cosa/ops/hpc_adaption.py b/angelslim/compressor/sparsity/algorithms/cosa/ops/hpc_adaption.py
new file mode 100644
index 00000000..f5184c41
--- /dev/null
+++ b/angelslim/compressor/sparsity/algorithms/cosa/ops/hpc_adaption.py
@@ -0,0 +1,99 @@
+# Copyright 2025 Tencent Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Layout adapters: dense ``(B, H, L, D)`` <-> hpc varlen + paged KV."""
+
+from __future__ import annotations
+
+import torch
+import torch.nn.functional as F
+
+__all__ = [
+ "convert_to_varlen_q",
+ "convert_to_paged_kv",
+ "convert_varlen_out_to_bshd",
+]
+
+
+def convert_to_varlen_q(query_states: torch.Tensor, dtype: torch.dtype = torch.bfloat16):
+ """``(B, H, S, D)`` -> ``(B*S, H, D)`` plus ``cu_seqlens``."""
+ if query_states.dim() != 4:
+ raise ValueError(
+ f"query_states must be 4-D (B, H, S, D), got {tuple(query_states.shape)}."
+ )
+ B, H_q, q_len, D = query_states.shape
+ if D != 128:
+ raise ValueError(f"hpc kernel requires head_dim == 128, got {D}.")
+
+ q = query_states.permute(0, 2, 1, 3).reshape(B * q_len, H_q, D).to(dtype).contiguous()
+ cu_seqlens_q = torch.arange(0, B + 1, device=query_states.device, dtype=torch.int32) * int(
+ q_len
+ )
+ return q, cu_seqlens_q, int(q_len)
+
+
+def convert_to_paged_kv(
+ key_states: torch.Tensor,
+ value_states: torch.Tensor,
+ page_size: int = 64,
+ logical_block_size: int = 128,
+ dtype: torch.dtype = torch.bfloat16,
+):
+ """``(B, H, S, D)`` -> paged ``(B*pages, page_size, H, D)`` + page table."""
+ if page_size not in (32, 64):
+ raise ValueError(f"page_size must be 32 or 64 (hpc paged block size), got {page_size}.")
+ if logical_block_size % page_size != 0:
+ raise ValueError(
+ f"logical_block_size ({logical_block_size}) must be a multiple of "
+ f"page_size ({page_size})."
+ )
+ if key_states.shape != value_states.shape:
+ raise ValueError(
+ f"key/value must share shape, got {tuple(key_states.shape)} vs "
+ f"{tuple(value_states.shape)}."
+ )
+ if key_states.dim() != 4:
+ raise ValueError(f"key/value must be 4-D (B, H, S, D), got {tuple(key_states.shape)}.")
+
+ B, H_kv, k_len, D = key_states.shape
+ if D != 128:
+ raise ValueError(f"hpc kernel requires head_dim == 128, got {D}.")
+
+ k_block_num = (k_len + logical_block_size - 1) // logical_block_size
+ k_pad = k_block_num * logical_block_size
+ pages_per_batch = k_pad // page_size
+
+ def _to_paged(x: torch.Tensor) -> torch.Tensor:
+ if k_pad != k_len:
+ x = F.pad(x, (0, 0, 0, k_pad - k_len))
+ x = x.permute(0, 2, 1, 3).reshape(B * pages_per_batch, page_size, H_kv, D)
+ return x.to(dtype).contiguous()
+
+ kcache = _to_paged(key_states)
+ vcache = _to_paged(value_states)
+ block_ids = torch.arange(
+ B * pages_per_batch, device=key_states.device, dtype=torch.int32
+ ).reshape(B, pages_per_batch)
+ seqlens_kvcache = torch.full((B,), int(k_len), device=key_states.device, dtype=torch.int32)
+ return kcache, vcache, block_ids, seqlens_kvcache
+
+
+def convert_varlen_out_to_bshd(out: torch.Tensor, batch_size: int, q_len: int) -> torch.Tensor:
+ """``(B*S, H, D)`` -> ``(B, H, S, D)``."""
+ total_seq, H_q, D = out.shape
+ if total_seq != batch_size * q_len:
+ raise ValueError(
+ f"out total_seq ({total_seq}) != batch_size*q_len ({batch_size}*{q_len})."
+ )
+ return out.reshape(batch_size, q_len, H_q, D).permute(0, 2, 1, 3).contiguous()
diff --git a/angelslim/compressor/sparsity/algorithms/cosa/ops/proxy.py b/angelslim/compressor/sparsity/algorithms/cosa/ops/proxy.py
new file mode 100644
index 00000000..da24d777
--- /dev/null
+++ b/angelslim/compressor/sparsity/algorithms/cosa/ops/proxy.py
@@ -0,0 +1,481 @@
+# Copyright 2025 Tencent Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""CoSA block-selection proxy.
+
+Strided ``Q @ K^T`` -> per-block mass / row-max flags -> keep the smallest
+prefix whose cumulative mass covers ``topp`` (plus sink / diagonal /
+row-max), emitted as priority-ordered block ids for the hpc kernel.
+"""
+
+from __future__ import annotations
+
+import math
+
+import torch
+import torch.nn.functional as F
+import triton
+import triton.language as tl
+
+
+@triton.jit
+def _flat_group_gemm_fuse_reshape_kernel(
+ Q,
+ K,
+ Out,
+ stride_qz,
+ stride_qh,
+ stride_qn,
+ stride_kz,
+ stride_kh,
+ stride_kn,
+ stride_oz,
+ stride_oh,
+ stride_on,
+ chunk_start,
+ chunk_end,
+ H: tl.constexpr,
+ STRIDE: tl.constexpr,
+ HEAD_DIM: tl.constexpr,
+ BLOCK_M: tl.constexpr,
+ BLOCK_N: tl.constexpr,
+):
+ block_m = tl.program_id(0).to(tl.int64)
+ block_n = tl.program_id(1).to(tl.int64)
+ batch_id = tl.program_id(2).to(tl.int64) // H
+ head_id = tl.program_id(2).to(tl.int64) % H
+
+ if chunk_start + (block_m + 1) * BLOCK_M <= block_n * BLOCK_N:
+ return
+
+ Q_ptrs = (
+ Q + batch_id * stride_qz + head_id * stride_qh + block_m * BLOCK_M * STRIDE * stride_qn
+ )
+ K_ptrs = (
+ K + batch_id * stride_kz + head_id * stride_kh + block_n * BLOCK_N * STRIDE * stride_kn
+ )
+ Q_ptrs = (
+ Q_ptrs
+ + tl.arange(0, BLOCK_M)[:, None] * (stride_qn * STRIDE)
+ + tl.arange(0, HEAD_DIM)[None, :]
+ + stride_qn * (STRIDE - 1)
+ )
+ K_ptrs = (
+ K_ptrs
+ + tl.arange(0, BLOCK_N)[None, :] * (stride_kn * STRIDE)
+ + tl.arange(0, HEAD_DIM)[:, None]
+ )
+
+ o = tl.zeros([BLOCK_M, BLOCK_N], dtype=tl.float32)
+ for iter in range(STRIDE):
+ q = tl.load(Q_ptrs - iter * stride_qn)
+ k = tl.load(K_ptrs + iter * stride_kn)
+ o += tl.dot(q, k)
+
+ O_ptrs = (
+ Out
+ + batch_id * stride_oz
+ + head_id * stride_oh
+ + block_m * BLOCK_M * stride_on
+ + block_n * BLOCK_N
+ )
+ O_ptrs = O_ptrs + tl.arange(0, BLOCK_M)[:, None] * stride_on + tl.arange(0, BLOCK_N)[None, :]
+ tl.store(O_ptrs, o.to(Out.type.element_ty))
+
+
+def flat_group_gemm_fuse_reshape(query_states, key_states, stride, chunk_start, chunk_end):
+ batch_size, num_heads, q_len, head_dim = query_states.shape
+ kv_len = key_states.shape[2]
+
+ assert key_states.shape[0] == batch_size
+ assert key_states.shape[1] == num_heads
+ assert key_states.shape[3] == head_dim
+
+ output = torch.empty(
+ (batch_size, num_heads, q_len // stride, kv_len // stride),
+ dtype=query_states.dtype,
+ device=query_states.device,
+ )
+ BLOCK_M = 128
+ BLOCK_N = 128
+ assert q_len % (stride * BLOCK_M) == 0
+ assert kv_len % (stride * BLOCK_N) == 0
+
+ grid = (q_len // stride // BLOCK_M, kv_len // stride // BLOCK_N, batch_size * num_heads)
+ _flat_group_gemm_fuse_reshape_kernel[grid](
+ query_states,
+ key_states,
+ output,
+ query_states.stride(0),
+ query_states.stride(1),
+ query_states.stride(2),
+ key_states.stride(0),
+ key_states.stride(1),
+ key_states.stride(2),
+ output.stride(0),
+ output.stride(1),
+ output.stride(2),
+ chunk_start,
+ chunk_end,
+ num_heads,
+ stride,
+ head_dim,
+ BLOCK_M,
+ BLOCK_N,
+ )
+ return output
+
+
+@triton.jit
+def _fused_cosa_proxy_kernel(
+ In,
+ Out_score,
+ Out_rowmax,
+ scale,
+ input_stride_0,
+ input_stride_1,
+ input_stride_2,
+ score_stride_0,
+ score_stride_1,
+ score_stride_2,
+ rowmax_stride_0,
+ rowmax_stride_1,
+ rowmax_stride_2,
+ real_q_len,
+ k_len,
+ chunk_start,
+ chunk_end,
+ segment_size: tl.constexpr,
+ block_size: tl.constexpr,
+):
+ block_id = tl.program_id(0)
+ head_id = tl.program_id(1)
+ batch_id = tl.program_id(2)
+
+ offs_q = tl.arange(0, block_size) + chunk_start + block_id * block_size
+ offs_k = tl.arange(0, segment_size)
+
+ num_iters = k_len // segment_size
+ num_iters_before_causal = (chunk_start + (block_id + 1) * block_size - 1) // segment_size
+
+ m_i = tl.zeros([block_size], dtype=tl.float32) - float("inf")
+
+ input_ptr = (
+ In
+ + batch_id * input_stride_0
+ + head_id * input_stride_1
+ + block_id * block_size * input_stride_2
+ )
+ input_ptr = (
+ input_ptr + tl.arange(0, segment_size) + tl.arange(0, block_size)[:, None] * input_stride_2
+ )
+
+ score_ptr = (
+ Out_score
+ + batch_id * score_stride_0
+ + head_id * score_stride_1
+ + block_id * score_stride_2
+ )
+ score_ptr = score_ptr + tl.arange(0, segment_size // block_size)
+
+ rowmax_ptr = (
+ Out_rowmax
+ + batch_id * rowmax_stride_0
+ + head_id * rowmax_stride_1
+ + block_id * rowmax_stride_2
+ )
+ rowmax_ptr = rowmax_ptr + tl.arange(0, segment_size // block_size)
+
+ for iter in range(0, num_iters_before_causal):
+ X = tl.load(input_ptr + iter * segment_size).to(tl.float32) * scale
+ m_i = tl.maximum(m_i, tl.max(X, 1))
+
+ for iter in range(num_iters_before_causal, num_iters_before_causal + 1):
+ X = tl.load(input_ptr + iter * segment_size).to(tl.float32) * scale
+ mask = offs_q[:, None] >= (offs_k[None, :] + iter * segment_size)
+ X = tl.where(mask, X, -1.0e6)
+ m_i = tl.maximum(m_i, tl.max(X, 1))
+
+ sum_mask = offs_q[:, None] < real_q_len
+
+ for iter in range(0, num_iters_before_causal):
+ X = tl.load(input_ptr + iter * segment_size).to(tl.float32) * scale
+ X = tl.reshape(X, (block_size, segment_size // block_size, block_size))
+ blockscore = tl.max(X, 2)
+ E = tl.exp2(blockscore - m_i[:, None])
+ E = tl.where(sum_mask, E, 0)
+ score = tl.sum(E, 0)
+ hr = tl.max(tl.where(E >= (1.0 - 1e-3), 1.0, 0.0), 0)
+ tl.store(
+ score_ptr + iter * segment_size // block_size, score.to(Out_score.type.element_ty)
+ )
+ tl.store(rowmax_ptr + iter * segment_size // block_size, hr.to(Out_rowmax.type.element_ty))
+
+ for iter in range(num_iters_before_causal, num_iters_before_causal + 1):
+ X = tl.load(input_ptr + iter * segment_size).to(tl.float32) * scale
+ mask = offs_q[:, None] >= (offs_k[None, :] + iter * segment_size)
+ X = tl.where(mask, X, -1.0e6)
+ X = tl.reshape(X, (block_size, segment_size // block_size, block_size))
+ blockscore = tl.max(X, 2)
+ E = tl.exp2(blockscore - m_i[:, None])
+ E = tl.where(sum_mask, E, 0)
+ score = tl.sum(E, 0)
+ hr = tl.max(tl.where(E >= (1.0 - 1e-3), 1.0, 0.0), 0)
+ tl.store(
+ score_ptr + iter * segment_size // block_size, score.to(Out_score.type.element_ty)
+ )
+ tl.store(rowmax_ptr + iter * segment_size // block_size, hr.to(Out_rowmax.type.element_ty))
+
+ for iter in range(num_iters_before_causal + 1, num_iters):
+ zeros = tl.zeros([segment_size // block_size], dtype=tl.float32)
+ tl.store(
+ score_ptr + iter * segment_size // block_size, zeros.to(Out_score.type.element_ty)
+ )
+ tl.store(
+ rowmax_ptr + iter * segment_size // block_size, zeros.to(Out_rowmax.type.element_ty)
+ )
+
+
+def fused_cosa_proxy(
+ attn_weights_slice,
+ reshaped_block_size,
+ segment_size,
+ chunk_start,
+ chunk_end,
+ real_q_len,
+ scale,
+):
+ batch_size, num_heads, q_len, k_len = attn_weights_slice.shape
+ assert q_len % reshaped_block_size == 0
+ assert k_len % segment_size == 0
+ assert segment_size % reshaped_block_size == 0
+ assert attn_weights_slice.stride(-1) == 1
+
+ attn_score = torch.empty(
+ (batch_size, num_heads, q_len // reshaped_block_size, k_len // reshaped_block_size),
+ dtype=attn_weights_slice.dtype,
+ device=attn_weights_slice.device,
+ )
+ has_rowmax = torch.empty(
+ (batch_size, num_heads, q_len // reshaped_block_size, k_len // reshaped_block_size),
+ dtype=torch.int8,
+ device=attn_weights_slice.device,
+ )
+ grid = (q_len // reshaped_block_size, num_heads, batch_size)
+ _fused_cosa_proxy_kernel[grid](
+ attn_weights_slice,
+ attn_score,
+ has_rowmax,
+ scale,
+ attn_weights_slice.stride(0),
+ attn_weights_slice.stride(1),
+ attn_weights_slice.stride(2),
+ attn_score.stride(0),
+ attn_score.stride(1),
+ attn_score.stride(2),
+ has_rowmax.stride(0),
+ has_rowmax.stride(1),
+ has_rowmax.stride(2),
+ real_q_len,
+ k_len,
+ chunk_start,
+ chunk_end,
+ segment_size,
+ reshaped_block_size,
+ )
+ return attn_score, has_rowmax
+
+
+def select_and_order_blocks_row_max(
+ mass: torch.Tensor,
+ has_rowmax: torch.Tensor,
+ q_block_num: int,
+ k_block_num: int,
+ threshold: float,
+) -> torch.Tensor:
+ batch_size, head_num, chunk_num, block_num = mass.shape
+ device = mass.device
+ mass = mass.to(torch.float32)
+ has_rowmax = has_rowmax.to(torch.bool)
+
+ threshold = float(threshold)
+ total_sum = mass.sum(dim=-1, keepdim=True)
+ if threshold == 1.0:
+ # inf budget: total_sum * 1.0 can drop the last block to float rounding.
+ required_sum = torch.full_like(total_sum, float("inf"))
+ else:
+ required_sum = total_sum * threshold
+
+ causal = torch.tril(
+ torch.ones(chunk_num, block_num, dtype=torch.bool, device=device),
+ diagonal=block_num - chunk_num,
+ ).view(1, 1, chunk_num, block_num)
+
+ mandatory = torch.zeros((1, 1, chunk_num, block_num), dtype=torch.bool, device=device)
+ mandatory[:, :, :, 0] = True
+ rows = torch.arange(chunk_num, device=device)
+ diag_cols = rows + (block_num - chunk_num)
+ mandatory[0, 0, rows, diag_cols] = True
+ mandatory = mandatory & causal
+
+ C = total_sum.amax() + 1.0
+ priority = mass + C * has_rowmax.to(mass.dtype) + (2.0 * C) * mandatory.to(mass.dtype)
+ neg_inf = torch.full_like(priority, float("-inf"))
+ priority = torch.where(causal.expand_as(priority), priority, neg_inf)
+
+ sort_perm = priority.argsort(dim=-1, descending=True)
+ mass_in_order = torch.gather(mass, dim=-1, index=sort_perm)
+ cum_before = torch.cat(
+ [
+ torch.zeros((batch_size, head_num, chunk_num, 1), device=device),
+ mass_in_order[:, :, :, :-1],
+ ],
+ dim=-1,
+ ).cumsum(dim=-1)
+ keep_in_order = cum_before < required_sum
+
+ mand_in_order = torch.gather(mandatory.expand_as(mass).to(torch.bool), dim=-1, index=sort_perm)
+ causal_in_order = torch.gather(causal.expand_as(mass).to(torch.bool), dim=-1, index=sort_perm)
+ kept = (keep_in_order | mand_in_order) & causal_in_order
+
+ row_blockmask = sort_perm.to(torch.int32)
+ row_blockmask = torch.where(kept, row_blockmask, torch.full_like(row_blockmask, -1))
+ assert not (row_blockmask == -1).all(dim=-1).any(), "row_blockmask contains a row with all -1"
+ return row_blockmask.contiguous()
+
+
+def _select_chunk(
+ attn_sum_chunk,
+ has_rowmax_chunk,
+ chunk_idx,
+ npc,
+ offset_u,
+ q_block_num_u,
+ k_block_num_u,
+ topp,
+):
+ rows_lo = chunk_idx * npc
+ rows_hi = min((chunk_idx + 1) * npc, q_block_num_u)
+ n_rows = rows_hi - rows_lo
+ kv_u = rows_hi + offset_u
+
+ mass_chunk = attn_sum_chunk[:, :, :n_rows, :kv_u].contiguous()
+ hr_chunk = has_rowmax_chunk[:, :, :n_rows, :kv_u].contiguous()
+ sel = select_and_order_blocks_row_max(mass_chunk, hr_chunk, n_rows, kv_u, topp)
+ if kv_u < k_block_num_u:
+ sel = F.pad(sel, (0, k_block_num_u - kv_u), value=-1)
+ return sel
+
+
+def _validate_topp(topp) -> float:
+ if isinstance(topp, bool) or not isinstance(topp, (int, float)):
+ raise TypeError(f"topp must be a scalar float, got {type(topp).__name__}.")
+ return float(topp)
+
+
+def cosa_estimate(
+ query_states: torch.Tensor,
+ key_states: torch.Tensor,
+ block_size: int,
+ stride: int,
+ topp: float = 0.8,
+ chunk_size: int = 16384,
+):
+ topp = _validate_topp(topp)
+
+ _, _, k_len, head_dim = key_states.shape
+ _, _, q_len, _ = query_states.shape
+
+ k_num_to_pad = ((k_len + chunk_size - 1) // chunk_size) * chunk_size - k_len
+ q_num_to_pad = ((q_len + chunk_size - 1) // chunk_size) * chunk_size - q_len
+ k_chunk_num = (k_len + k_num_to_pad) // chunk_size
+ k_block_num = (k_len + k_num_to_pad) // block_size
+ q_block_num = (q_len + q_num_to_pad) // block_size
+ q_chunk_num = (q_len + q_num_to_pad) // chunk_size
+ assert k_chunk_num >= q_chunk_num
+
+ # Keep F.pad on the input device (do not force "cuda:0" under device_map).
+ if k_num_to_pad > 0:
+ pad_key_states = F.pad(key_states, (0, 0, 0, k_num_to_pad), value=0)
+ else:
+ pad_key_states = key_states
+ if q_num_to_pad > 0:
+ pad_query_states = F.pad(query_states, (0, 0, 0, q_num_to_pad), value=0)
+ else:
+ pad_query_states = query_states
+
+ reshaped_chunk_size = chunk_size // stride
+ reshaped_block_size = block_size // stride
+ k_reshaped_num_to_pad = k_num_to_pad // stride
+ k_reshaped_seq_len = (k_len + k_num_to_pad) // stride
+ num_blocks_per_chunk = reshaped_chunk_size // reshaped_block_size
+
+ q_block_num_u = (q_len + block_size - 1) // block_size
+ k_block_num_u = (k_len + block_size - 1) // block_size
+ offset_u = k_block_num_u - q_block_num_u
+ npc = num_blocks_per_chunk
+
+ scale = 1.4426950408889634 / math.sqrt(head_dim) / stride
+ segment_size = min(4096, reshaped_block_size)
+ real_q_len = k_reshaped_seq_len - k_reshaped_num_to_pad
+
+ attn_sum_list = []
+ row_blockmask_list = []
+
+ for chunk_idx in range(q_chunk_num):
+ chunk_start = (
+ k_block_num - q_block_num
+ ) * reshaped_block_size + chunk_idx * reshaped_chunk_size
+ chunk_end = chunk_start + reshaped_chunk_size
+ attn_weights_slice = flat_group_gemm_fuse_reshape(
+ pad_query_states[
+ :,
+ :,
+ (chunk_idx * reshaped_chunk_size)
+ * stride : (chunk_idx * reshaped_chunk_size + reshaped_chunk_size)
+ * stride,
+ :,
+ ],
+ pad_key_states,
+ stride,
+ chunk_start,
+ chunk_end,
+ )
+ attn_sum, has_rowmax = fused_cosa_proxy(
+ attn_weights_slice,
+ reshaped_block_size,
+ segment_size,
+ chunk_start,
+ chunk_end,
+ real_q_len,
+ scale,
+ )
+ del attn_weights_slice
+
+ sel = _select_chunk(
+ attn_sum,
+ has_rowmax,
+ chunk_idx,
+ npc,
+ offset_u,
+ q_block_num_u,
+ k_block_num_u,
+ topp,
+ )
+ attn_sum_list.append(attn_sum)
+ row_blockmask_list.append(sel)
+
+ attn_sums = torch.cat(attn_sum_list, dim=-2)
+ second = torch.cat(row_blockmask_list, dim=-2)
+ return attn_sums, second
diff --git a/angelslim/compressor/sparsity/algorithms/cosa/prefill.py b/angelslim/compressor/sparsity/algorithms/cosa/prefill.py
new file mode 100644
index 00000000..bd2ce759
--- /dev/null
+++ b/angelslim/compressor/sparsity/algorithms/cosa/prefill.py
@@ -0,0 +1,96 @@
+# Copyright 2025 Tencent Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""CoSA prefill: Triton selection proxy + ``hpc`` block-sparse attention.
+
+The proxy emits priority-ordered block ids; the ``hpc`` kernel walks that order
+and threshold-skips the tail. Both ``triton`` and ``hpc`` are hard dependencies.
+Inputs are post-repeat ``(B, H, L, D)``.
+"""
+
+from __future__ import annotations
+
+import contextlib
+
+import torch
+
+from .ops.hpc_adaption import (
+ convert_to_paged_kv,
+ convert_to_varlen_q,
+ convert_varlen_out_to_bshd,
+)
+from .ops.proxy import cosa_estimate
+
+_HPC_PAGE_SIZE = 64
+
+
+def default_chunk_size(k_len: int) -> int:
+ """Pick a proxy chunk size that keeps peak memory roughly constant."""
+ pow2 = 1 << (k_len - 1).bit_length()
+ return int(max(min(max(2048, pow2), 128 * 1024 * 2048 // pow2), 2048))
+
+
+def cosa_prefill(
+ q: torch.Tensor,
+ k: torch.Tensor,
+ v: torch.Tensor,
+ *,
+ stride: int,
+ topp: float,
+ block_size: int,
+ chunk_size,
+ skipsoftmax_threshold: float,
+) -> torch.Tensor:
+ """Prefill. ``q,k,v`` and the return are post-repeat ``(B, H, L, D)``."""
+ import hpc
+
+ batch_size, _, k_len, _ = k.shape
+ q_len = q.shape[2]
+
+ assert block_size == 128, f"the hpc kernel is dim128/block128 only, got {block_size}"
+ assert batch_size == 1, f"CoSA prefill is validated at batch_size 1, got {batch_size}"
+
+ if chunk_size is None:
+ chunk_size = default_chunk_size(k_len)
+
+ # Triton launches on the *current* device; pin to q's GPU under device_map.
+ pin = torch.cuda.device(q.device) if q.is_cuda else contextlib.nullcontext()
+ with pin:
+ attn_sums, ordered_blockmask = cosa_estimate(
+ q,
+ k,
+ block_size=block_size,
+ stride=stride,
+ topp=topp,
+ chunk_size=chunk_size,
+ )
+ del attn_sums
+
+ q_thd, cu_seqlens_q, max_seqlens_q = convert_to_varlen_q(q)
+ kcache, vcache, block_ids, seqlens_kvcache = convert_to_paged_kv(
+ k, v, page_size=_HPC_PAGE_SIZE, logical_block_size=block_size
+ )
+ hpc_out = hpc.attention_with_kvcache_blocksparse_anyorderskip_prefill_bf16(
+ q_thd,
+ kcache,
+ vcache,
+ cu_seqlens_q,
+ block_ids,
+ seqlens_kvcache,
+ max_seqlens_q,
+ ordered_blockmask,
+ float(skipsoftmax_threshold),
+ )
+
+ return convert_varlen_out_to_bshd(hpc_out, batch_size, q_len)
diff --git a/configs/sparse/cosa/hy3_cosa.yaml b/configs/sparse/cosa/hy3_cosa.yaml
new file mode 100644
index 00000000..8478b64c
--- /dev/null
+++ b/configs/sparse/cosa/hy3_cosa.yaml
@@ -0,0 +1,31 @@
+# CoSA ordered block-sparse prefill (Hunyuan-V3 / hy_v3).
+# Triton proxy selects key-blocks by topp mass coverage; hpc walks the
+# priority-ordered list and threshold-skips via skipsoftmax_threshold.
+# Hard deps: triton + internal hpc; bf16, head_dim 128, block_size 128, batch=1.
+# NOTE: full Hunyuan-V3 (~80 layers) needs device_map: auto across visible GPUs.
+# Run: python tools/run.py -c configs/sparse/cosa/hy3_cosa.yaml
+
+global:
+ save_path: ./output
+
+model:
+ name: HYV3MoE
+ model_path: tencent/Hy3
+ trust_remote_code: true
+ low_cpu_mem_usage: true
+ use_cache: false
+ torch_dtype: auto
+ device_map: auto
+ attn_implementation: flash_attention_2
+
+compression:
+ name: Sparsity
+ sparsity:
+ name: cosa
+ allow_pseudo_sparse: false # CoSA has no torch reference; true is rejected
+ attn_kwargs:
+ stride: 8 # proxy sub-sample stride
+ topp: 0.95 # mass coverage in (0, 1]; 1.0 = keep all
+ block_size: 128 # hpc is dim128/block128 only
+ chunk_size: 2048 # proxy chunk; multiple of stride*128
+ skipsoftmax_threshold: 1000 # runtime skip; < 0 disables
diff --git a/configs/sparse/cosa/qwen3-8b_cosa.yaml b/configs/sparse/cosa/qwen3-8b_cosa.yaml
new file mode 100644
index 00000000..87f1ad3d
--- /dev/null
+++ b/configs/sparse/cosa/qwen3-8b_cosa.yaml
@@ -0,0 +1,28 @@
+# CoSA ordered block-sparse prefill (Qwen3-8B).
+# Run: python tools/run.py -c configs/sparse/cosa/qwen3-8b_cosa.yaml
+
+global:
+ save_path: ./output
+
+model:
+ name: Qwen
+ model_path: Qwen/Qwen3-8B
+ trust_remote_code: true
+ low_cpu_mem_usage: true
+ use_cache: false
+ torch_dtype: auto
+ device_map: auto
+ # q3 default: decode runs flash-attention-2; sparse prefill bypasses it.
+ attn_implementation: flash_attention_2
+
+compression:
+ name: Sparsity
+ sparsity:
+ name: cosa
+ allow_pseudo_sparse: false # CoSA has no torch reference; true is rejected
+ attn_kwargs:
+ stride: 8 # proxy sub-sample stride
+ topp: 0.95 # mass coverage in (0, 1]; 1.0 = keep all
+ block_size: 128 # hpc is dim128/block128 only
+ chunk_size: 2048 # proxy chunk; multiple of stride*128
+ skipsoftmax_threshold: 1000 # runtime skip; < 0 disables
diff --git a/docs/source/features/sparse_attention/index.md b/docs/source/features/sparse_attention/index.md
index 906e23a0..40e75227 100644
--- a/docs/source/features/sparse_attention/index.md
+++ b/docs/source/features/sparse_attention/index.md
@@ -14,6 +14,7 @@
| XAttention | 实验性 | 内置 kernel(MIT 来源);head_dim 256 走 torch 参考。 |
| FlashPrefill | 实验性 | clean-room 实现;`alpha<=0` dense 路径需要 `flash_attn`。 |
| VecAttention | 仅参考实现 | 快速 kernel 依赖尚未公开发布的 vLLM-flash-attention fork;外部用户默认走 torch 参考路径(`allow_pseudo_sparse: true`)。 |
+| CoSA | 实验性 | proxy 为内置 Triton;attention 依赖内部 `hpc` 扩展(非公开可安装)。 |
通用约束(所有算法):不支持 padding mask(请用 batch=1 或无 padding 输入)、部分 kernel 要求 batch=1、prefix-cache / chunked prefill 会自动回退到 dense(稀疏仅在真正首填 `k_len==q_len` 时生效)、不支持 sliding-window 层、不支持多卡 / TP(`WORLD_SIZE>1` 会被拒绝)、稀疏 prefill 不支持 `output_attentions=True`、**不能与量化组合**。`allow_pseudo_sparse` 是正确性 / 调试回退路径,不是性能路径。
diff --git a/tests/sparse/test_cosa_integration.py b/tests/sparse/test_cosa_integration.py
new file mode 100644
index 00000000..f0fd0865
--- /dev/null
+++ b/tests/sparse/test_cosa_integration.py
@@ -0,0 +1,341 @@
+# Copyright 2025 Tencent Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""CoSA integration tests.
+
+CoSA has exactly one code path — Triton proxy plus the internal ``hpc`` kernel,
+both hard dependencies — so a forward can only be exercised where ``hpc`` is
+installed. Everything else here (registration, traits, config validation, the
+layout adapters, the patch lifecycle) is pure CPU and runs anywhere torch
+imports; the layout-adapter tests matter most, since a silent shape mistake in
+the varlen/paged conversion would surface as wrong numbers rather than a crash.
+
+Run: python tests/sparse/test_cosa_integration.py
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+
+import torch
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from _harness import CUDA # noqa: E402
+from _harness import HEAD_DIM # noqa: E402
+from _harness import FakeSlim # noqa: E402
+from _harness import Skip # noqa: E402
+from _harness import SkipReason # noqa: E402
+from _harness import patched # noqa: E402
+from _harness import real_qwen3_8b # noqa: E402
+from _harness import real_weights_available # noqa: E402
+from _harness import record_capability # noqa: E402
+from _harness import rel # noqa: E402
+from _harness import run_all # noqa: E402
+from _harness import tiny_qwen3 # noqa: E402
+
+
+def _make_algo(attn_kwargs=None):
+ import angelslim.compressor.sparsity.algorithms # noqa: F401 register
+ from angelslim.compressor.sparsity.registry import SparsityAlgorithmRegistry
+
+ return SparsityAlgorithmRegistry.create("cosa", attn_kwargs=dict(attn_kwargs or {}))
+
+
+class _StubModel:
+ """Minimal stand-in exposing only the config fields ``setup`` reads."""
+
+ def __init__(self, num_attention_heads=4, num_hidden_layers=4, model_type="qwen3"):
+ self.config = type(
+ "_Cfg",
+ (),
+ {
+ "num_attention_heads": num_attention_heads,
+ "num_hidden_layers": num_hidden_layers,
+ "model_type": model_type,
+ },
+ )()
+
+
+def _hpc_available() -> bool:
+ try:
+ import hpc # noqa: F401
+ except Exception: # noqa: BLE001
+ return False
+ return True
+
+
+# ===========================================================================
+# Registration + traits (no weights)
+# ===========================================================================
+def test_cosa_registered_and_traits():
+ import angelslim.compressor.sparsity.algorithms # noqa: F401
+ from angelslim.compressor.sparsity.registry import SparsityAlgorithmRegistry
+
+ assert "cosa" in SparsityAlgorithmRegistry._factories
+ algo = _make_algo()
+ assert algo.name == "cosa"
+ t = algo.traits
+ assert t.requires_unrepeated_kv is False
+ assert t.needs_calibration is False
+ assert t.supports_padding_mask is False
+ assert t.model_modal == "any"
+ for mt in ("qwen3", "qwen3_moe", "hy_v3"):
+ assert mt in t.compatible_model_types, mt
+
+
+def test_cosa_rejects_incompatible_model_type():
+ from angelslim.compressor.sparsity._base import IncompatibleConfigError
+ from angelslim.compressor.sparsity.patcher import _check_model_type
+
+ algo = _make_algo()
+ _check_model_type(algo, _StubModel(model_type="qwen3")) # supported: no raise
+ try:
+ _check_model_type(algo, _StubModel(model_type="qwen3_5_text"))
+ except IncompatibleConfigError as e:
+ assert "qwen3_5_text" in str(e)
+ else:
+ raise AssertionError("CoSA must reject a model_type outside its compatibility set")
+
+
+# ===========================================================================
+# Config validation
+# ===========================================================================
+def test_cosa_setup_defaults():
+ algo = _make_algo()
+ algo.setup(_StubModel())
+ cfg = algo._cfg
+ assert cfg["stride"] == 8
+ assert cfg["topp"] == 0.8
+ assert cfg["block_size"] == 128
+ assert cfg["chunk_size"] is None # derived per prefill from k_len
+ assert cfg["skipsoftmax_threshold"] == 1000
+ assert "allow_pseudo_sparse" not in cfg, "CoSA has no fallback path to configure"
+
+
+def test_cosa_rejects_allow_pseudo_sparse():
+ """The knob promises a torch fallback CoSA does not have; refuse, never ignore."""
+ from angelslim.compressor.sparsity._base import IncompatibleConfigError
+
+ algo = _make_algo({"allow_pseudo_sparse": True})
+ try:
+ algo.setup(_StubModel())
+ except IncompatibleConfigError as e:
+ assert "allow_pseudo_sparse" in str(e)
+ else:
+ raise AssertionError("allow_pseudo_sparse=true must be rejected, not silently ignored")
+
+
+def test_cosa_setup_rejects_bad_stride():
+ from angelslim.compressor.sparsity._base import IncompatibleConfigError
+
+ algo = _make_algo({"stride": 0})
+ try:
+ algo.setup(_StubModel())
+ except IncompatibleConfigError as e:
+ assert "stride" in str(e)
+ else:
+ raise AssertionError("stride < 1 must be rejected")
+
+
+def test_cosa_topp_must_be_a_scalar():
+ """Only a scalar topp is accepted; per-head tables are rejected by design."""
+ from angelslim.compressor.sparsity._base import IncompatibleConfigError
+
+ algo = _make_algo({"topp": 1}) # an int is fine, and normalizes to float
+ algo.setup(_StubModel())
+ assert algo._cfg["topp"] == 1.0
+ assert isinstance(algo._cfg["topp"], float)
+
+ # A per-head list sized correctly for the model must STILL be refused —
+ # silently accepting one would resurrect the table path.
+ for bad in ([0.8, 0.9, 0.85, 0.95], (0.8, 0.9), "0.8", None, True):
+ wrong = _make_algo({"topp": bad})
+ try:
+ wrong.setup(_StubModel(num_attention_heads=4))
+ except IncompatibleConfigError as e:
+ assert "scalar" in str(e), f"{bad!r} -> {e}"
+ else:
+ raise AssertionError(f"topp={bad!r} must be rejected as a non-scalar")
+
+
+def test_cosa_topp_range_is_checked():
+ from angelslim.compressor.sparsity._base import IncompatibleConfigError
+
+ for bad in (0.0, -0.1, 1.5):
+ algo = _make_algo({"topp": bad})
+ try:
+ algo.setup(_StubModel())
+ except IncompatibleConfigError as e:
+ assert "(0, 1]" in str(e), f"{bad} -> {e}"
+ else:
+ raise AssertionError(f"topp={bad} is outside (0, 1] and must be rejected")
+
+
+def test_cosa_default_chunk_size_tiles_cleanly():
+ from angelslim.compressor.sparsity.algorithms.cosa.prefill import default_chunk_size
+
+ for k_len in (2048, 4096, 16384, 65536, 131072):
+ cs = default_chunk_size(k_len)
+ assert cs >= 2048, f"chunk_size floor violated at k_len={k_len}: {cs}"
+ # The proxy GEMM tiles the reduced matrix at 128; at the documented
+ # stride 8 the chunk must cover whole tiles or the kernel cannot launch.
+ assert cs % (8 * 128) == 0, f"k_len={k_len} -> chunk_size={cs} untileable"
+
+
+# ===========================================================================
+# hpc layout adapters (CPU) — a silent shape bug here means wrong numbers
+# ===========================================================================
+def test_cosa_varlen_q_roundtrip():
+ from angelslim.compressor.sparsity.algorithms.cosa.ops.hpc_adaption import (
+ convert_to_varlen_q,
+ convert_varlen_out_to_bshd,
+ )
+
+ b, h, s, d = 1, 4, 384, HEAD_DIM
+ q = torch.randn(b, h, s, d, dtype=torch.bfloat16)
+ q_thd, cu_seqlens, max_seq = convert_to_varlen_q(q)
+
+ assert q_thd.shape == (b * s, h, d)
+ assert max_seq == s
+ assert cu_seqlens.dtype == torch.int32
+ assert cu_seqlens.tolist() == [0, s]
+ assert q_thd.is_contiguous()
+
+ # The kernel returns (total_tokens, H, D); converting back must be exact.
+ back = convert_varlen_out_to_bshd(q_thd, b, s)
+ assert back.shape == q.shape
+ assert torch.equal(back, q)
+
+
+def test_cosa_paged_kv_layout():
+ from angelslim.compressor.sparsity.algorithms.cosa.ops.hpc_adaption import (
+ convert_to_paged_kv,
+ )
+
+ b, h, s, d = 1, 2, 384, HEAD_DIM
+ page = 64
+ k = torch.randn(b, h, s, d, dtype=torch.bfloat16)
+ v = torch.randn(b, h, s, d, dtype=torch.bfloat16)
+ kcache, vcache, block_ids, seqlens = convert_to_paged_kv(k, v, page_size=page)
+
+ pages = s // page
+ assert kcache.shape == (b * pages, page, h, d)
+ assert vcache.shape == kcache.shape
+ assert block_ids.shape == (b, pages)
+ assert block_ids.dtype == torch.int32
+ assert seqlens.tolist() == [s]
+
+ # Page p must hold tokens [p*page, (p+1)*page) for every head.
+ for p in (0, pages // 2, pages - 1):
+ expect = k[0, :, p * page : (p + 1) * page, :].permute(1, 0, 2)
+ assert torch.equal(kcache[p], expect), f"page {p} holds the wrong tokens"
+
+
+def test_cosa_paged_kv_pads_partial_block():
+ """A k_len that is not a whole number of 128-blocks must be padded up."""
+ from angelslim.compressor.sparsity.algorithms.cosa.ops.hpc_adaption import (
+ convert_to_paged_kv,
+ )
+
+ b, h, d = 1, 2, HEAD_DIM
+ s = 3 * 128 + 40 # 424 -> padded to 512
+ k = torch.randn(b, h, s, d, dtype=torch.bfloat16)
+ kcache, _, block_ids, seqlens = convert_to_paged_kv(k, k.clone(), page_size=64)
+
+ assert kcache.shape[0] == b * (512 // 64)
+ assert block_ids.shape == (b, 512 // 64)
+ # seqlens still reports the REAL length; padding is addressable but not read.
+ assert seqlens.tolist() == [s]
+
+
+def test_cosa_adapters_reject_wrong_head_dim():
+ from angelslim.compressor.sparsity.algorithms.cosa.ops.hpc_adaption import (
+ convert_to_paged_kv,
+ convert_to_varlen_q,
+ )
+
+ bad = torch.randn(1, 2, 256, 64, dtype=torch.bfloat16)
+ for fn in (lambda: convert_to_varlen_q(bad), lambda: convert_to_paged_kv(bad, bad.clone())):
+ try:
+ fn()
+ except ValueError as e:
+ assert "128" in str(e)
+ else:
+ raise AssertionError("head_dim != 128 must be refused by the hpc adapters")
+
+
+# ===========================================================================
+# Patch lifecycle (structure only — no forward, so no kernel needed)
+# ===========================================================================
+def test_cosa_patches_every_layer_and_restores():
+ model = tiny_qwen3(num_layers=4)
+ slim = FakeSlim(model)
+ algo = _make_algo({"topp": 0.8})
+
+ layers = list(model.model.layers)
+ originals = [layer.self_attn.forward for layer in layers]
+ with patched(slim, algo) as handles:
+ assert len(handles) == model.config.num_hidden_layers
+ assert slim.attn_forward_labels() == {"sparse"}
+ for layer in layers:
+ assert layer.self_attn.attn_forward_config["topp"] == 0.8
+ for i, layer in enumerate(layers):
+ assert layer.self_attn.forward == originals[i], "unpatch did not restore the forward"
+ assert slim.attn_forward_labels() == set()
+
+
+# ===========================================================================
+# Numerical correctness (needs real weights AND the internal hpc extension)
+# ===========================================================================
+def test_cosa_keep_all_matches_dense():
+ if not real_weights_available():
+ raise Skip("real Qwen3-8B weights unavailable", SkipReason.NO_QWEN3_8B)
+ if not _hpc_available():
+ raise Skip("internal hpc extension unavailable", SkipReason.NO_BLOCK_SPARSE_ATTN)
+
+ m = real_qwen3_8b()
+ torch.manual_seed(0)
+ ids = torch.randint(0, 151000, (1, 2048), device="cuda")
+ with torch.no_grad():
+ dense = m(ids).logits.float()
+
+ # topp == 1.0 keeps every causal block and skipsoftmax_threshold < 0 disables
+ # the runtime skip, so the two together must reproduce dense attention.
+ slim = FakeSlim(m)
+ algo = _make_algo(
+ {"topp": 1.0, "stride": 8, "chunk_size": 2048, "skipsoftmax_threshold": -1.0}
+ )
+ with patched(slim, algo) as handles:
+ assert len(handles) == m.config.num_hidden_layers
+ with torch.no_grad():
+ sp = m(ids).logits.float()
+ with torch.no_grad():
+ restored = m(ids).logits.float()
+
+ assert not torch.isnan(sp).any()
+ err = rel(sp, dense)
+ assert err < 0.02, f"keep-all CoSA should match dense, rel={err}"
+ assert dense[0, -1].argmax() == sp[0, -1].argmax(), "keep-all argmax drift"
+ assert torch.equal(restored, dense), "unpatch did not restore the real model"
+ record_capability("cosa_real_correctness")
+
+
+if __name__ == "__main__":
+ sys.exit(
+ run_all(
+ dict(globals()),
+ summary_extra=f"cuda={CUDA} hpc={_hpc_available()}",
+ )
+ )