# Copyright (c) Meta Platforms, Inc. and affiliates
# Owner(s): ["oncall: distributed"]

"""
NPU context parallel SDPA regression tests.

The main scenario intentionally mirrors PyTorch's
RingAttentionTest.test_ring_attention_sdpa so torch_npu validates the same
context-parallel user behavior as native DTensor. NPU-specific assertions also
cover the fused npu_fusion_attention_v3 dispatcher path, ring softmax merge,
BNSD layout handling, communication counts, bf16 tolerance, and gradient
unsharding behavior.
"""

from collections.abc import Callable

import torch
import torch.distributed as dist
import torch.nn.functional as F
import torch_npu
import torch_npu.distributed.tensor.experimental._context_parallel._attention
from torch.distributed.tensor import DeviceMesh
from torch.distributed.tensor.debug import CommDebugMode
from torch.distributed.tensor.experimental._attention import (
    _context_parallel_shard,
    _ContextParallel,
    _cp_options,
    _disable_context_parallel_dispatcher,
    _enable_context_parallel_dispatcher,
    _HeadTailLoadBalancer,
    _RotateMethod,
    context_parallel,
    context_parallel_unshard,
    set_rotate_method,
)
from torch.distributed.tensor.parallel import parallelize_module
from torch.nn.attention import sdpa_kernel, SDPBackend

from torch_npu.testing._internal.common_dtensor import NPUDTensorTestBase
from torch_npu.testing.common_distributed import skipIfUnsupportMultiNPU, with_comms
from torch_npu.testing.common_utils import SupportedDevices
from torch_npu.testing.testcase import run_tests


c10d_functional = torch.ops.c10d_functional

ROTATER_ENUM_TO_STR = {
    _RotateMethod.ALL_GATHER: "allgather",
    _RotateMethod.ALL_TO_ALL: "alltoall",
}

ATTENTION_TOLERANCES = {
    torch.bfloat16: (1e-2, 8e-3),
    torch.float32: (2e-6, 1e-5),
}

def _ordered_bf16_bits(x: torch.Tensor) -> torch.Tensor:
    bits = x.detach().to(torch.bfloat16).cpu().contiguous().view(torch.int16).to(torch.int32)
    bits = torch.where(bits < 0, bits + 65536, bits)
    sign = (bits & 0x8000) != 0
    return torch.where(sign, 0xFFFF - bits, bits + 0x8000)


def _bf16_ulp_diff(actual: torch.Tensor, expected: torch.Tensor) -> torch.Tensor:
    return (_ordered_bf16_bits(actual) - _ordered_bf16_bits(expected)).abs()


class SDPAWrapper(torch.nn.Module):
    def __init__(self, compiled: bool, backend: SDPBackend) -> None:
        super().__init__()
        self.compiled = compiled
        self.backend = backend
        if compiled:
            self._compiled_sdpa = torch.compile(
                F.scaled_dot_product_attention,
                fullgraph=True,
                backend="aot_eager",
            )

    def forward(self, *args: object, **kwargs: object) -> torch.Tensor:
        with sdpa_kernel(self.backend):
            if self.compiled:
                return self._compiled_sdpa(*args, **kwargs)
            return F.scaled_dot_product_attention(*args, **kwargs)


class TestContextParallelAttention(NPUDTensorTestBase):
    @property
    def world_size(self) -> int:
        device_count = torch.npu.device_count() if torch.npu.is_available() else 0
        return min(8, device_count) if device_count >= 2 else 2

    def _make_load_balancer(
        self,
        load_balance: bool,
        seq_length: int,
    ) -> _HeadTailLoadBalancer | None:
        if not load_balance:
            return None
        return _HeadTailLoadBalancer(seq_length, self.world_size, torch.device(self.device_type))

    def _ring_attention_sdpa(
        self,
        cp_q: torch.Tensor,
        cp_k: torch.Tensor,
        cp_v: torch.Tensor,
        *,
        fn_eval: Callable,
        mesh: DeviceMesh,
        seq_dim: int,
        is_causal: bool,
        compiled: bool,
        backend: SDPBackend,
        rotater: _RotateMethod,
        test_forward_only: bool,
        load_balance: bool,
        use_context: bool,
    ) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None, torch.Tensor | None, int]:
        ring_update_call_count = 0
        original_ring_update = getattr(torch_npu, "npu_ring_attention_update", None)

        if callable(original_ring_update):
            def counted_ring_update(*args, **kwargs):
                nonlocal ring_update_call_count
                ring_update_call_count += 1
                return original_ring_update(*args, **kwargs)

            torch_npu.npu_ring_attention_update = counted_ring_update

        cp_context = None
        try:
            if not use_context:
                cp_plan = _ContextParallel(
                    seq_dim=seq_dim,
                    attention_type=_ContextParallel.AttentionType.SDPA,
                )
                attention = parallelize_module(SDPAWrapper(compiled=compiled, backend=backend), mesh, cp_plan)
                load_balancer = self._make_load_balancer(load_balance, cp_q.size(seq_dim))
                cp_q, cp_k, cp_v = _context_parallel_shard(
                    mesh,
                    (cp_q, cp_k, cp_v),
                    (seq_dim,) * 3,
                    load_balancer=load_balancer,
                )
                _enable_context_parallel_dispatcher()
            else:
                _cp_options.enable_load_balance = load_balance
                cp_context = context_parallel(
                    mesh,
                    buffers=(cp_q, cp_k, cp_v),
                    buffer_seq_dims=(seq_dim,) * 3,
                )
                cp_context.__enter__()
                attention = F.scaled_dot_product_attention
                if compiled:
                    attention = torch.compile(attention, fullgraph=True, backend="aot_eager")

            for target in (cp_q, cp_k, cp_v):
                target.requires_grad = True

            with CommDebugMode() as comm_mode:
                with sdpa_kernel(backend):
                    cp_out = fn_eval(
                        attention,
                        cp_q,
                        cp_k,
                        cp_v,
                        is_causal=is_causal,
                    )

                if not compiled and rotater == _RotateMethod.ALL_TO_ALL:
                    expected_all2all = (
                        self.world_size - 1
                        if test_forward_only
                        else self.world_size * 3 - 2
                    )
                    self.assertDictEqual(
                        comm_mode.get_comm_counts(),
                        {c10d_functional.all_to_all_single: expected_all2all},
                    )

            cp_dq, cp_dk, cp_dv = cp_q.grad, cp_k.grad, cp_v.grad
            for target in (cp_q, cp_k, cp_v):
                target.requires_grad = False
            return cp_out, cp_dq, cp_dk, cp_dv, ring_update_call_count
        finally:
            if not use_context:
                _disable_context_parallel_dispatcher()
            elif cp_context is not None:
                cp_context.__exit__(None, None, None)
            if callable(original_ring_update):
                torch_npu.npu_ring_attention_update = original_ring_update

    @SupportedDevices(["Ascend910B"])
    @skipIfUnsupportMultiNPU(2)
    @with_comms
    def test_ring_attention_sdpa(self) -> None:
        self.run_subtests(
            {
                "is_causal": [True, False],
                "compiled": [False, True],
                "backend": [SDPBackend.OVERRIDEABLE],
                "load_balance": [False, True],
                "rotater": [_RotateMethod.ALL_TO_ALL, _RotateMethod.ALL_GATHER],
                "test_forward_only": [False, True],
                "use_context": [False, True],
                "dtype": [torch.bfloat16, torch.float32],
            },
            self._test_ring_attention_sdpa,
        )

    def _test_ring_attention_sdpa(
        self,
        is_causal: bool,
        compiled: bool,
        backend: SDPBackend,
        load_balance: bool,
        rotater: _RotateMethod,
        test_forward_only: bool,
        use_context: bool,
        dtype: torch.dtype,
    ) -> None:
        if load_balance and not is_causal:
            return

        set_rotate_method(ROTATER_ENUM_TO_STR[rotater])
        self.assertEqual(_cp_options.rotate_method, rotater)

        device_mesh = DeviceMesh(self.device_type, torch.arange(0, self.world_size))
        bs = 8
        seq_length = 1024
        seq_dim = 2
        dim = 32
        nheads = 8

        torch.manual_seed(10)
        q, k, v = [
            torch.rand(
                (bs, nheads, seq_length * self.world_size, dim),
                device=self.device_type,
                dtype=dtype,
                requires_grad=True,
            )
            for _ in range(3)
        ]

        with torch.no_grad():
            dist.broadcast(q, src=0)
            dist.broadcast(k, src=0)
            dist.broadcast(v, src=0)

        def fn_eval(fn, *args, **kwargs):
            if test_forward_only:
                with torch.no_grad():
                    return fn(*args, **kwargs)
            out = fn(*args, **kwargs)
            out.sum().backward()
            return out

        with sdpa_kernel(backend):
            out = fn_eval(F.scaled_dot_product_attention, q, k, v, is_causal=is_causal)

        cp_q, cp_k, cp_v = [target.detach().clone() for target in (q, k, v)]
        cp_out, cp_dq, cp_dk, cp_dv, ring_update_call_count = self._ring_attention_sdpa(
            cp_q,
            cp_k,
            cp_v,
            fn_eval=fn_eval,
            mesh=device_mesh,
            seq_dim=seq_dim,
            is_causal=is_causal,
            compiled=compiled,
            backend=backend,
            rotater=rotater,
            test_forward_only=test_forward_only,
            load_balance=load_balance,
            use_context=use_context,
        )

        call_count = torch.tensor([ring_update_call_count], device=self.device_type)
        dist.all_reduce(call_count)
        if callable(getattr(torch_npu, "npu_ring_attention_update", None)):
            self.assertGreater(call_count.item(), 0)

        load_balancer = self._make_load_balancer(load_balance, q.size(seq_dim))
        (cp_out,) = context_parallel_unshard(
            device_mesh,
            [cp_out],
            [seq_dim],
            load_balancer=load_balancer,
        )

        atol, rtol = ATTENTION_TOLERANCES[dtype]
        torch.testing.assert_close(out, cp_out, atol=atol, rtol=rtol)
        if dtype == torch.bfloat16:
            self.assertLessEqual(int(_bf16_ulp_diff(cp_out, out).max().item()), 1)

        if test_forward_only:
            return

        cp_dq, cp_dk, cp_dv = context_parallel_unshard(
            device_mesh,
            [cp_dq, cp_dk, cp_dv],
            [seq_dim] * 3,
            load_balancer=load_balancer,
        )

        torch.testing.assert_close(q.grad, cp_dq, atol=atol, rtol=rtol)
        torch.testing.assert_close(k.grad, cp_dk, atol=atol, rtol=rtol)
        torch.testing.assert_close(v.grad, cp_dv, atol=atol, rtol=rtol)
        if dtype == torch.bfloat16:
            self.assertLessEqual(int(_bf16_ulp_diff(cp_dv, v.grad).max().item()), 1)


if __name__ == "__main__":
    run_tests()