# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you 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.
# pylint: disable=missing-docstring
"""Tests for NCCL/RCCL"""

import tempfile

import numpy as np
import pytest

import tvm
import tvm.testing
from tvm import get_global_func
from tvm import relax as rx
from tvm.runtime import disco as di
from tvm.runtime.vm import VirtualMachine
from tvm.s_tir import dlight as dl
from tvm.script import relax as R

if di is None:
    pytest.skip("disco runtime is not available", allow_module_level=True)

_all_session_kinds = [di.ThreadedSession, di.ProcessSession]
_compiled_ccl = get_global_func("runtime.disco.compiled_ccl", allow_missing=True)
if _compiled_ccl is None:
    pytest.skip("Disco CCL is not enabled in this TVM build", allow_module_level=True)
_ccl = [_compiled_ccl()]


def create_device_target(ccl):
    if ccl == "nccl":
        dev = tvm.cuda(0)
    else:
        dev = tvm.rocm(0)
    target = tvm.target.Target.from_device(dev)
    return (dev, target)


def _run_with_ccl_session(session_kind, ccl, devices, func, *, num_groups=1):
    def run_and_check():
        sess = session_kind(num_workers=len(devices), num_groups=num_groups)
        try:
            sess.init_ccl(ccl, *devices)
            return func(sess)
        finally:
            sess.shutdown()

    return tvm.testing.run_with_gpu_lock(run_and_check)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_init(session_kind, ccl):
    devices = [0, 1]

    def run_test(_sess):
        pass

    _run_with_ccl_session(session_kind, ccl, devices, run_test)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_allreduce(session_kind, ccl):
    devices = [0, 1]
    array_1 = np.arange(12, dtype="float32").reshape(3, 4)
    array_2 = np.arange(start=1, stop=-11, step=-1, dtype="float32").reshape(3, 4)

    def run_test(sess):
        d_array = sess.empty((3, 4), "float32")
        d_array.debug_copy_from(0, array_1)
        d_array.debug_copy_from(1, array_2)
        for op, np_op in [  # pylint: disable=invalid-name
            ("sum", np.add),
            ("prod", np.multiply),
            ("min", np.minimum),
            ("max", np.maximum),
            ("avg", lambda a, b: (a + b) * 0.5),
        ]:
            dst_array = sess.empty((3, 4), "float32")
            sess.allreduce(d_array, dst_array, op=op)
            result = dst_array.debug_get_from_remote(0).numpy()
            expected = np_op(array_1, array_2)
            np.testing.assert_equal(result, expected)

    _run_with_ccl_session(session_kind, ccl, devices, run_test)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_group_allreduce(session_kind, ccl):
    devices = [0, 1, 2, 3]
    array_1 = np.arange(12, dtype="float32").reshape(3, 4)
    array_2 = np.arange(start=1, stop=-11, step=-1, dtype="float32").reshape(3, 4)
    array_3 = np.arange(30, dtype="float32").reshape(5, 6)
    array_4 = np.arange(start=1, stop=-29, step=-1, dtype="float32").reshape(5, 6)

    def run_test(sess):
        d_array_1 = sess.empty((3, 4), "float32")
        d_array_2 = sess.empty((5, 6), "float32")
        d_array_1.debug_copy_from(0, array_1)
        d_array_1.debug_copy_from(1, array_2)
        d_array_2.debug_copy_from(2, array_3)
        d_array_2.debug_copy_from(3, array_4)
        for op, np_op in [  # pylint: disable=invalid-name
            ("sum", np.add),
            ("prod", np.multiply),
            ("min", np.minimum),
            ("max", np.maximum),
            ("avg", lambda a, b: (a + b) * 0.5),
        ]:
            dst_array_1 = sess.empty((3, 4), "float32")
            dst_array_2 = sess.empty((5, 6), "float32")
            sess.allreduce(d_array_1, dst_array_1, op=op, in_group=True)
            sess.allreduce(d_array_2, dst_array_2, op=op, in_group=True)
            result_1 = dst_array_1.debug_get_from_remote(0).numpy()
            result_2 = dst_array_2.debug_get_from_remote(2).numpy()
            expected_1 = np_op(array_1, array_2)
            expected_2 = np_op(array_3, array_4)
            np.testing.assert_equal(result_1, expected_1)
            np.testing.assert_equal(result_2, expected_2)

    _run_with_ccl_session(session_kind, ccl, devices, run_test, num_groups=2)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_allgather(session_kind, ccl):
    devices = [0, 1]
    array = np.arange(36, dtype="float32")

    def run_test(sess):
        d_src = sess.empty((3, 3, 2), "float32")
        d_dst = sess.empty((3, 4, 3), "float32")
        d_src.debug_copy_from(0, array[:18])
        d_src.debug_copy_from(1, array[18:])
        sess.allgather(d_src, d_dst)
        np.testing.assert_equal(
            d_dst.debug_get_from_remote(0).numpy(),
            array.reshape(3, 4, 3),
        )
        np.testing.assert_equal(
            d_dst.debug_get_from_remote(1).numpy(),
            array.reshape(3, 4, 3),
        )

    _run_with_ccl_session(session_kind, ccl, devices, run_test)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_group_allgather(session_kind, ccl):
    devices = [0, 1, 2, 3]
    array_1 = np.arange(36, dtype="float32")
    array_2 = np.arange(48, dtype="float32")

    def run_test(sess):
        d_src_1 = sess.empty((3, 3, 2), "float32")
        d_dst_1 = sess.empty((3, 4, 3), "float32")
        d_src_2 = sess.empty((2, 4, 3), "float32")
        d_dst_2 = sess.empty((2, 6, 4), "float32")
        d_src_1.debug_copy_from(0, array_1[:18])
        d_src_1.debug_copy_from(1, array_1[18:])
        d_src_2.debug_copy_from(2, array_2[:24])
        d_src_2.debug_copy_from(3, array_2[24:])
        sess.allgather(d_src_1, d_dst_1, in_group=True)
        sess.allgather(d_src_2, d_dst_2, in_group=True)
        np.testing.assert_equal(
            d_dst_1.debug_get_from_remote(0).numpy(),
            array_1.reshape(3, 4, 3),
        )
        np.testing.assert_equal(
            d_dst_1.debug_get_from_remote(1).numpy(),
            array_1.reshape(3, 4, 3),
        )
        np.testing.assert_equal(
            d_dst_2.debug_get_from_remote(2).numpy(),
            array_2.reshape(2, 6, 4),
        )
        np.testing.assert_equal(
            d_dst_2.debug_get_from_remote(3).numpy(),
            array_2.reshape(2, 6, 4),
        )

    _run_with_ccl_session(session_kind, ccl, devices, run_test, num_groups=2)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
@pytest.mark.parametrize("use_explicit_output", [True, False])
def test_broadcast(session_kind, ccl, use_explicit_output):
    devices = [0, 1]
    array = np.arange(12, dtype="float32").reshape(3, 4)

    def run_test(sess):
        if use_explicit_output:
            src_array = sess.empty((3, 4), "float32", worker0_only=True)
            src_array.debug_copy_from(0, array)
            dst_array = sess.empty((3, 4), "float32")
            sess.broadcast_from_worker0(src_array, dst_array)
        else:
            dst_array = sess.broadcast(array)

        result = dst_array.debug_get_from_remote(1).numpy()
        np.testing.assert_equal(result, array)

    _run_with_ccl_session(session_kind, ccl, devices, run_test)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_group_broadcast(session_kind, ccl):
    devices = [0, 1, 2, 3]
    array_1 = np.arange(12, dtype="float32").reshape(3, 4)
    array_2 = np.multiply(array_1, -1)

    def run_test(sess):
        src_array = sess.empty((3, 4), "float32", worker0_only=True, in_group=True)
        src_array.debug_copy_from(0, array_1)
        src_array.debug_copy_from(2, array_2)
        dst_array = sess.empty((3, 4), "float32")
        sess.broadcast_from_worker0(src_array, dst_array)

        result_1 = dst_array.debug_get_from_remote(1).numpy()
        np.testing.assert_equal(result_1, array_1)

        result_3 = dst_array.debug_get_from_remote(3).numpy()
        np.testing.assert_equal(result_3, array_2)

    _run_with_ccl_session(session_kind, ccl, devices, run_test, num_groups=2)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
@pytest.mark.parametrize("use_explicit_output", [True, False])
def test_scatter(session_kind, ccl, use_explicit_output, capfd):
    devices = [0, 1]
    array = np.arange(36, dtype="float32").reshape(2, 6, 3)

    def run_test(sess):
        if use_explicit_output:
            d_src = sess.empty((2, 6, 3), "float32", worker0_only=True)
            d_dst = sess.empty((6, 3), "float32")
            d_src.debug_copy_from(0, array)
            sess.scatter_from_worker0(d_src, d_dst)
        else:
            d_dst = sess.scatter(array)

        np.testing.assert_equal(
            d_dst.debug_get_from_remote(0).numpy(),
            array[0, :, :],
        )
        np.testing.assert_equal(
            d_dst.debug_get_from_remote(1).numpy(),
            array[1, :, :],
        )

        captured = capfd.readouterr()
        assert not captured.err, (
            "No warning messages should be generated from disco.Session.scatter_from_worker0"
        )

    _run_with_ccl_session(session_kind, ccl, devices, run_test)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_group_scatter(session_kind, ccl, capfd):
    devices = [0, 1, 2, 3]
    array_1 = np.arange(36, dtype="float32").reshape(2, 6, 3)
    array_2 = np.multiply(array_1, -1)

    def run_test(sess):
        d_src = sess.empty((2, 6, 3), "float32", worker0_only=True, in_group=True)
        d_src.debug_copy_from(0, array_1)
        d_src.debug_copy_from(2, array_2)
        d_dst = sess.empty((6, 3), "float32")
        sess.scatter_from_worker0(d_src, d_dst)

        np.testing.assert_equal(
            d_dst.debug_get_from_remote(0).numpy(),
            array_1[0, :, :],
        )
        np.testing.assert_equal(
            d_dst.debug_get_from_remote(1).numpy(),
            array_1[1, :, :],
        )
        np.testing.assert_equal(
            d_dst.debug_get_from_remote(2).numpy(),
            array_2[0, :, :],
        )
        np.testing.assert_equal(
            d_dst.debug_get_from_remote(3).numpy(),
            array_2[1, :, :],
        )

        captured = capfd.readouterr()
        assert not captured.err, (
            "No warning messages should be generated from disco.Session.scatter_from_worker0"
        )

    _run_with_ccl_session(session_kind, ccl, devices, run_test, num_groups=2)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_scatter_with_implicit_reshape(session_kind, ccl, capfd):
    """Scatter may perform an implicit reshape

    Scattering elements to the workers requires the total number of
    elements to be divisible by the number of workers.  It does not
    necessarily correspond to scattering across the outermost
    dimension.  Here, the number of workers (2) and the outermost
    dimension (3) are not divisible, but the scatter may still be
    performed.

    This is only allowed when the caller explicitly uses the
    `sess.scatter_from_worker0` method, and is not allowed in
    `sess.scatter` method.  Because the `sess.scatter` method may
    perform an allocation on the disco workers, it requires that the
    scatter occur across the outermost dimension.

    """
    devices = [0, 1]
    array = np.arange(36, dtype="float32").reshape(3, 4, 3)

    def run_test(sess):
        d_src = sess.empty((3, 4, 3), "float32", worker0_only=True)
        d_dst = sess.empty((3, 3, 2), "float32")
        d_src.debug_copy_from(0, array)
        sess.scatter_from_worker0(d_src, d_dst)

        np.testing.assert_equal(
            d_dst.debug_get_from_remote(0).numpy(),
            array.flat[:18].reshape(3, 3, 2),
        )
        np.testing.assert_equal(
            d_dst.debug_get_from_remote(1).numpy(),
            array.flat[18:].reshape(3, 3, 2),
        )

        captured = capfd.readouterr()
        assert not captured.err, (
            "No warning messages should be generated from disco.Session.scatter_from_worker0"
        )

    _run_with_ccl_session(session_kind, ccl, devices, run_test)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_gather(session_kind, ccl, capfd):
    devices = [0, 1]
    array = np.arange(36, dtype="float32")

    def run_test(sess):
        d_src = sess.empty((3, 3, 2), "float32")
        d_dst = sess.empty((3, 4, 3), "float32", worker0_only=True)
        d_src.debug_copy_from(0, array[:18])
        d_src.debug_copy_from(1, array[18:])
        sess.gather_to_worker0(d_src, d_dst)
        np.testing.assert_equal(
            d_dst.debug_get_from_remote(0).numpy(),
            array.reshape(3, 4, 3),
        )

        captured = capfd.readouterr()
        assert not captured.err, (
            "No warning messages should be generated from disco.Session.gather_to_worker0"
        )

    _run_with_ccl_session(session_kind, ccl, devices, run_test)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_group_gather(session_kind, ccl, capfd):
    devices = [0, 1, 2, 3]
    array_1 = np.arange(36, dtype="float32")
    array_2 = np.multiply(array_1, -1)

    def run_test(sess):
        d_src = sess.empty((3, 3, 2), "float32")
        d_dst = sess.empty((3, 4, 3), "float32", worker0_only=True, in_group=True)
        d_src.debug_copy_from(0, array_1[:18])
        d_src.debug_copy_from(1, array_1[18:])
        d_src.debug_copy_from(2, array_2[:18])
        d_src.debug_copy_from(3, array_2[18:])
        sess.gather_to_worker0(d_src, d_dst)
        np.testing.assert_equal(
            d_dst.debug_get_from_remote(0).numpy(),
            array_1.reshape(3, 4, 3),
        )
        np.testing.assert_equal(
            d_dst.debug_get_from_remote(2).numpy(),
            array_2.reshape(3, 4, 3),
        )

        captured = capfd.readouterr()
        assert not captured.err, (
            "No warning messages should be generated from disco.Session.gather_to_worker0"
        )

    _run_with_ccl_session(session_kind, ccl, devices, run_test, num_groups=2)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_send_to_next_group_receive_from_prev_group(session_kind, ccl):
    devices = [0, 1, 2, 3]
    array_1 = np.arange(12, dtype="float32").reshape(3, 4)
    array_2 = np.arange(start=1, stop=-11, step=-1, dtype="float32").reshape(3, 4)

    def run_test(sess):
        d_array = sess.empty((3, 4), "float32")
        d_array.debug_copy_from(0, array_1)
        d_array.debug_copy_from(1, array_2)
        sess.get_global_func(
            "runtime.disco." + ccl + ".test_send_to_next_group_recv_from_prev_group"
        )(d_array)

        result_1 = d_array.debug_get_from_remote(2).numpy()
        result_2 = d_array.debug_get_from_remote(3).numpy()
        np.testing.assert_equal(result_1, array_1)
        np.testing.assert_equal(result_2, array_2)

    _run_with_ccl_session(session_kind, ccl, devices, run_test, num_groups=2)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_worker2_send_to_worker0(session_kind, ccl):
    devices = [0, 1, 2, 3]
    array = np.arange(start=1, stop=-11, step=-1, dtype="float32").reshape(3, 4)

    def run_test(sess):
        d_array = sess.empty((3, 4), "float32")
        d_array.debug_copy_from(2, array)
        sess.get_global_func("runtime.disco." + ccl + ".test_worker2_sends_to_worker0")(d_array)

        result = d_array.debug_get_from_remote(0).numpy()
        np.testing.assert_equal(result, array)

    _run_with_ccl_session(session_kind, ccl, devices, run_test, num_groups=2)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_mlp(session_kind, ccl):  # pylint: disable=too-many-locals
    devices = [0, 1]

    # pylint: disable=invalid-name
    @tvm.script.ir_module
    class MLP:  # pylint: disable=too-few-public-methods
        @R.function
        def main(
            x: R.Tensor((128, 128), "float32"),
            W1: R.Tensor((128, 128), "float32"),
            W2: R.Tensor((128, 128), "float32"),
        ) -> R.Tensor((128, 128), "float32"):
            R.func_attr({"global_symbol": "main"})
            with R.dataflow():
                lv0: R.Tensor((128, 128), "float32") = R.matmul(x, W1)
                lv1: R.Tensor((128, 128), "float32") = R.nn.gelu(lv0)
                lv2: R.Tensor((128, 128), "float32") = R.matmul(lv1, W2)
                R.output(lv2)
            return lv2

    @tvm.script.ir_module
    class ShardedMLP:  # pylint: disable=too-few-public-methods
        @R.function
        def main(
            x: R.Tensor((128, 128), "float32"),
            W1: R.Tensor((128, 64), "float32"),  # shard along axis 1
            W2: R.Tensor((64, 128), "float32"),  # shard along axis 0
        ) -> R.Tensor((128, 128), "float32"):
            R.func_attr({"global_symbol": "main"})
            with R.dataflow():
                broadcast_x: R.Tensor((128, 128), "float32") = R.ccl.broadcast_from_worker0(x)
                lv0: R.Tensor((128, 64), "float32") = R.matmul(broadcast_x, W1)
                lv1: R.Tensor((128, 64), "float32") = R.nn.gelu(lv0)
                lv2: R.Tensor((128, 128), "float32") = R.matmul(lv1, W2)
                lv3: R.Tensor((128, 128), "float32") = R.ccl.allreduce(lv2, "sum")
                R.output(lv3)
            return lv3

    # pylint: enable=invalid-name
    dev, target = create_device_target(ccl)

    def relax_build(mod, target):
        with target:
            mod = rx.get_pipeline("zero")(mod)  # pylint: disable=no-value-for-parameter
            mod = dl.ApplyDefaultSchedule(  # pylint: disable=not-callable
                dl.gpu.Matmul(),
                dl.gpu.GEMV(),
                dl.gpu.Reduction(),
                dl.gpu.GeneralReduction(),
                dl.gpu.Fallback(),
            )(mod)
            return tvm.compile(mod, target=target)

    # pylint: disable=invalid-name
    X = np.random.randn(128, 128).astype("float32")
    W1 = np.random.randn(128, 128).astype("float32")
    W2 = np.random.randn(128, 128).astype("float32")
    expected_ex = relax_build(MLP, target)
    sharded_ex = relax_build(ShardedMLP, target)

    with tempfile.TemporaryDirectory() as tmpdir:
        path = tmpdir + "/test.so"
        sharded_ex.export_library(path)

        def run_test(sess):
            Y_expected = VirtualMachine(expected_ex, device=dev)["main"](
                tvm.runtime.tensor(X, device=dev),
                tvm.runtime.tensor(W1, device=dev),
                tvm.runtime.tensor(W2, device=dev),
            ).numpy()

            mod = sess.load_vm_module(path)
            d_X = sess.empty((128, 128), "float32")
            d_W1 = sess.empty((128, 64), "float32")
            d_W2 = sess.empty((64, 128), "float32")

            d_X.debug_copy_from(0, X)
            d_W1.debug_copy_from(0, W1[:, :64])
            d_W1.debug_copy_from(1, W1[:, 64:])
            d_W2.debug_copy_from(0, W2[:64, :])
            d_W2.debug_copy_from(1, W2[64:, :])
            d_Y = mod["main"](d_X, d_W1, d_W2)
            Y_result = tvm.runtime.empty((128, 128), "float32", device=dev)
            sess.copy_from_worker_0(Y_result, d_Y)
            sess.sync_worker_0()
            Y_result = Y_result.numpy()
            tvm.testing.assert_allclose(Y_result, Y_expected, rtol=1e-4, atol=1e-4)

        _run_with_ccl_session(session_kind, ccl, devices, run_test)
    # pylint: enable=invalid-name


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("ccl", _ccl)
def test_attention(session_kind, ccl):  # pylint: disable=too-many-locals,too-many-statements
    devices = [0, 1]

    # pylint: disable=invalid-name
    @tvm.script.ir_module
    class Attention:  # pylint: disable=too-few-public-methods
        @R.function
        def main(  # pylint: disable=too-many-locals
            x: R.Tensor((1, 10, 128), "float32"),
            Wq: R.Tensor((128, 512), "float32"),
            Wk: R.Tensor((128, 512), "float32"),
            Wv: R.Tensor((128, 512), "float32"),
            Wo: R.Tensor((512, 128), "float32"),
        ) -> R.Tensor((128, 128), "float32"):
            R.func_attr({"global_symbol": "main"})
            with R.dataflow():
                # q
                lv0: R.Tensor((1, 10, 512), "float32") = R.matmul(x, Wq)
                lv1: R.Tensor((1, 10, 8, 64), "float32") = R.reshape(lv0, [1, 10, 8, 64])
                lv2: R.Tensor((1, 8, 10, 64), "float32") = R.permute_dims(lv1, [0, 2, 1, 3])
                # k
                lv3: R.Tensor((1, 10, 512), "float32") = R.matmul(x, Wk)
                lv4: R.Tensor((1, 10, 8, 64), "float32") = R.reshape(lv3, [1, 10, 8, 64])
                lv5: R.Tensor((1, 8, 10, 64), "float32") = R.permute_dims(lv4, [0, 2, 1, 3])
                # v
                lv6: R.Tensor((1, 10, 512), "float32") = R.matmul(x, Wv)
                lv7: R.Tensor((1, 10, 8, 64), "float32") = R.reshape(lv6, [1, 10, 8, 64])
                lv8: R.Tensor((1, 8, 10, 64), "float32") = R.permute_dims(lv7, [0, 2, 1, 3])
                # softmax(q @ k / sqrt(dk))
                lv9: R.Tensor((1, 8, 64, 10), "float32") = R.permute_dims(lv5, [0, 1, 3, 2])
                lv10: R.Tensor((1, 8, 10, 10), "float32") = R.matmul(lv2, lv9)
                lv11: R.Tensor((1, 8, 10, 10), "float32") = R.multiply(
                    lv10, R.const(1 / 8, "float32")
                )
                lv12: R.Tensor((1, 8, 10, 10), "float32") = R.nn.softmax(lv11, axis=-1)
                # attn_weight @ v
                lv13: R.Tensor((1, 8, 10, 64), "float32") = R.matmul(lv12, lv8)
                lv14: R.Tensor((1, 10, 8, 64), "float32") = R.permute_dims(lv13, [0, 2, 1, 3])
                lv15: R.Tensor((1, 10, 512), "float32") = R.reshape(lv14, [1, 10, 512])
                # attn_output @ o
                lv16: R.Tensor((1, 10, 128), "float32") = R.matmul(lv15, Wo)
                R.output(lv16)
            return lv16

    @tvm.script.ir_module
    class ShardedAttention:  # pylint: disable=too-few-public-methods
        @R.function
        def main(  # pylint: disable=too-many-locals
            x: R.Tensor((1, 10, 128), "float32"),
            Wq: R.Tensor((128, 256), "float32"),  # shard along axis 1
            Wk: R.Tensor((128, 256), "float32"),  # shard along axis 1
            Wv: R.Tensor((128, 256), "float32"),  # shard along axis 1
            Wo: R.Tensor((256, 128), "float32"),  # shard along axis 0
        ) -> R.Tensor((128, 128), "float32"):
            R.func_attr({"global_symbol": "main"})
            with R.dataflow():
                broadcast_x: R.Tensor((1, 10, 128), "float32") = R.ccl.broadcast_from_worker0(x)
                # q
                lv0: R.Tensor((1, 10, 256), "float32") = R.matmul(broadcast_x, Wq)
                lv1: R.Tensor((1, 10, 4, 64), "float32") = R.reshape(lv0, [1, 10, 4, 64])
                lv2: R.Tensor((1, 4, 10, 64), "float32") = R.permute_dims(lv1, [0, 2, 1, 3])
                # k
                lv3: R.Tensor((1, 10, 256), "float32") = R.matmul(broadcast_x, Wk)
                lv4: R.Tensor((1, 10, 4, 64), "float32") = R.reshape(lv3, [1, 10, 4, 64])
                lv5: R.Tensor((1, 4, 10, 64), "float32") = R.permute_dims(lv4, [0, 2, 1, 3])
                # v
                lv6: R.Tensor((1, 10, 256), "float32") = R.matmul(broadcast_x, Wv)
                lv7: R.Tensor((1, 10, 4, 64), "float32") = R.reshape(lv6, [1, 10, 4, 64])
                lv8: R.Tensor((1, 4, 10, 64), "float32") = R.permute_dims(lv7, [0, 2, 1, 3])
                # softmax(q @ k / sqrt(dk))
                lv9: R.Tensor((1, 4, 64, 10), "float32") = R.permute_dims(lv5, [0, 1, 3, 2])
                lv10: R.Tensor((1, 4, 10, 10), "float32") = R.matmul(lv2, lv9)
                lv11: R.Tensor((1, 4, 10, 10), "float32") = R.multiply(
                    lv10, R.const(1 / 8, "float32")
                )
                lv12: R.Tensor((1, 4, 10, 10), "float32") = R.nn.softmax(lv11, axis=-1)
                # attn_weight @ v
                lv13: R.Tensor((1, 4, 10, 64), "float32") = R.matmul(lv12, lv8)
                lv14: R.Tensor((1, 10, 4, 64), "float32") = R.permute_dims(lv13, [0, 2, 1, 3])
                lv15: R.Tensor((1, 10, 256), "float32") = R.reshape(lv14, [1, 10, 256])
                # attn_output @ o
                lv16: R.Tensor((1, 10, 128), "float32") = R.matmul(lv15, Wo)
                lv17: R.Tensor((1, 10, 128), "float32") = R.ccl.allreduce(lv16, "sum")
                R.output(lv17)
            return lv17

    # pylint: enable=invalid-name
    dev, target = create_device_target(ccl)

    def relax_build(mod, target):
        with target:
            mod = rx.get_pipeline("zero")(mod)  # pylint: disable=no-value-for-parameter
            mod = dl.ApplyDefaultSchedule(  # pylint: disable=not-callable
                dl.gpu.Matmul(),
                dl.gpu.GEMV(),
                dl.gpu.Reduction(),
                dl.gpu.GeneralReduction(),
                dl.gpu.Fallback(),
            )(mod)
            return tvm.compile(mod, target=target)

    # pylint: disable=invalid-name
    X = np.random.randn(1, 10, 128).astype("float32")
    Wq = np.random.randn(128, 512).astype("float32")
    Wk = np.random.randn(128, 512).astype("float32")
    Wv = np.random.randn(128, 512).astype("float32")
    Wo = np.random.randn(512, 128).astype("float32")
    expected_ex = relax_build(Attention, target)
    sharded_ex = relax_build(ShardedAttention, target)

    with tempfile.TemporaryDirectory() as tmpdir:
        path = tmpdir + "/test.so"
        sharded_ex.export_library(path)

        def run_test(sess):
            Y_expected = VirtualMachine(expected_ex, device=dev)["main"](
                tvm.runtime.tensor(X, device=dev),
                tvm.runtime.tensor(Wq, device=dev),
                tvm.runtime.tensor(Wk, device=dev),
                tvm.runtime.tensor(Wv, device=dev),
                tvm.runtime.tensor(Wo, device=dev),
            ).numpy()

            mod = sess.load_vm_module(path)
            d_X = sess.empty((1, 10, 128), "float32")
            d_Wq = sess.empty((128, 256), "float32")
            d_Wk = sess.empty((128, 256), "float32")
            d_Wv = sess.empty((128, 256), "float32")
            d_Wo = sess.empty((256, 128), "float32")

            d_X.debug_copy_from(0, X)
            d_Wq.debug_copy_from(0, Wq[:, :256])
            d_Wq.debug_copy_from(1, Wq[:, 256:])
            d_Wk.debug_copy_from(0, Wk[:, :256])
            d_Wk.debug_copy_from(1, Wk[:, 256:])
            d_Wv.debug_copy_from(0, Wv[:, :256])
            d_Wv.debug_copy_from(1, Wv[:, 256:])
            d_Wo.debug_copy_from(0, Wo[:256, :])
            d_Wo.debug_copy_from(1, Wo[256:, :])
            d_Y = mod["main"](d_X, d_Wq, d_Wk, d_Wv, d_Wo)
            Y_result = tvm.runtime.empty((1, 10, 128), "float32", device=dev)
            sess.copy_from_worker_0(Y_result, d_Y)
            sess.sync_worker_0()
            Y_result = Y_result.numpy()
            tvm.testing.assert_allclose(Y_result, Y_expected, rtol=1e-3, atol=1e-3)

        _run_with_ccl_session(session_kind, ccl, devices, run_test)
    # pylint: enable=invalid-name


if __name__ == "__main__":
    tvm.testing.main()
