# Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
#
# See LICENSE for license information.

"""Linear API"""
import os
import logging
from typing import Any, Callable, Dict, Optional, Tuple, Union, List
import functools
import torch

import transformer_engine_torch as tex

from .base import (
    get_multi_stream_cublas_batchgemm_workspace,
    get_dummy_wgrad,
    TransformerEngineBaseModule,
    _2X_ACC_FPROP,
    _2X_ACC_DGRAD,
    _2X_ACC_WGRAD,
)
from ._common import WeightGradStore
from ..fp8 import get_fp8_te_dtype, FP8GlobalStateManager
from ..utils import (
    divide,
    cast_if_needed,
    assert_dim_for_fp8_exec,
    clear_tensor_data,
    init_method_constant,
    requires_grad,
)
from ..distributed import (
    set_tensor_model_parallel_attributes,
    get_distributed_world_size,
    is_fp8_activation_recompute_enabled,
    in_fp8_activation_recompute_phase,
)
from ..cpp_extensions import (
    batchgemm,
)
from ..constants import GemmParallelModes, dist_group_type
from ..jit import no_torch_dynamo
from ..graph import is_graph_capturing
from ..float8_tensor import Float8Tensor

# NVTE_DEBUG = 0/1 # disables/enables debug mode, default = 0
_NVTE_DEBUG = int(os.getenv("NVTE_DEBUG", "0"))
# NVTE_DEBUG_LEVEL = 0/1/2 # enables more and more verbose debug mode, default = 0
_NVTE_DEBUG_LEVEL = int(os.getenv("NVTE_DEBUG_LEVEL", "0"))
log_level = _NVTE_DEBUG * _NVTE_DEBUG_LEVEL
log_levels = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}
logging.basicConfig(
    format="[%(levelname)-8s | %(name)-19s]: %(message)s",
    level=log_levels[log_level if log_level in [0, 1, 2] else 2],
)

__all__ = ["BatchedLinear"]


"""
The offset for fp8_meta_index.
_GEMM_INPUT = 0
_GEMM_WEIGHT = num_gemms
_GEMM_OUTPUT = 2 * num_gemms
Must be properly set in BatchedLinear's initialization.
"""
_GEMM_INPUT = 0
_GEMM_WEIGHT = 0
_GEMM_OUTPUT = 0
_GRAD_OUTPUT = 0


class _BatchLinear(torch.autograd.Function):
    """BatchLinear semi-top level module
    Calls custom cuda extensions.
    """

    @staticmethod
    def forward(
        ctx,
        inp: torch.Tensor,
        m_splits: List[int],
        use_bias: bool,
        is_first_microbatch: Union[bool, None],
        fp8: bool,
        fp8_calibration: bool,
        wgrad_store: WeightGradStore,
        fp8_meta: Dict[str, Any],
        fuse_wgrad_accumulation: bool,
        cpu_offloading: bool,
        tp_group: Union[dist_group_type, None],
        tp_size: int,
        sequence_parallel: bool,
        tensor_parallel: bool,
        activation_dtype: torch.dtype,
        parallel_mode: Union[str, None],
        is_grad_enabled: bool,
        fine_grained_activation_offloading,
        *weights_and_biases: Union[Float8Tensor, torch.Tensor, None],
    ) -> torch.Tensor:
        batch_num = int(os.getenv("NVTE_MOE_BATCHCOUNT", "2"))
        logger = logging.getLogger("BatchLinear")
        num_gemms = len(m_splits)
        weights = weights_and_biases[:num_gemms]
        weights_fp8 = weights_and_biases[num_gemms : 2 * num_gemms]
        biases = weights_and_biases[2 * num_gemms :]

        # Make sure input dimensions are compatible
        in_features = weights[0].shape[-1]
        assert inp.shape[-1] == in_features, "GEMM not possible"
        inputmats = torch.split(inp.view(-1, in_features), m_splits)
        if fp8:
            assert False, "BatchLinear does not support fp8 yet."

        # Cast input to expected dtype
        inputmats_no_fp8 = [cast_if_needed(mat, activation_dtype) for mat in inputmats]
        inputmats = []
        inputmats_t = []
        
        global _GEMM_INPUT, _GEMM_WEIGHT, _GEMM_OUTPUT
        inputmats = inputmats_no_fp8

        logger.debug("Running forward in %s", activation_dtype)

        # Cast for native AMP
        weights = [cast_if_needed(w, activation_dtype) for w in weights]
        biases = (
            [cast_if_needed(bias, activation_dtype) for bias in biases] if use_bias else biases
        )
        assert weights[0].size(0) % batch_num == 0, "weights[0].size(0) should be batch_num multiply."

        out = torch.empty(
            [sum(m_splits), int(weights[0].size(0) // batch_num)],
            dtype=activation_dtype,
            device=inputmats[0].device,
        )
        _ = batchgemm(
            weights,
            inputmats,
            torch.split(out, m_splits),
            activation_dtype,
            get_multi_stream_cublas_batchgemm_workspace(),
            bias=biases,
            use_bias=use_bias,
        )

        if is_grad_enabled:
            saved_inputmats = [None] * num_gemms
            saved_inputmats_t = [None] * num_gemms
            if weights[0].requires_grad:
                saved_inputmats = inputmats_no_fp8

                if cpu_offloading:
                    if fuse_wgrad_accumulation:
                        for w in weights:
                            w.main_grad.weight_offloading = True
                    for w in weights:
                        w.weight_offloading = True
                    for t in saved_inputmats:
                        if t is not None:
                            t.activation_offloading = True

            for i in range(num_gemms):
                weights[i].offloading_activation = False
                weights[i].main_grad.offloading_activation = False
                if weights_fp8[i] is not None:
                    weights_fp8[i].offloading_activation = False

            ctx.fine_grained_activation_offloading = fine_grained_activation_offloading

            if fine_grained_activation_offloading and cpu_offloading:
                raise ValueError(
                    f"Do not use fine_grained_activation_offloading and cpu_offloading at the same time."
                )

            if (
                fine_grained_activation_offloading
                and weights[0].requires_grad
                and fuse_wgrad_accumulation
            ):
                grad_added_to_main_grad_list = []
                for weight in weights:
                    if weight.requires_grad and hasattr(weight, "grad_added_to_main_grad"):
                        grad_added_to_main_grad_list.append(weight.grad_added_to_main_grad)
                        weight.grad_added_to_main_grad = True
                    else:
                        grad_added_to_main_grad_list.append(None)
                ctx.grad_added_to_main_grad_list = grad_added_to_main_grad_list

            ctx.save_for_backward(
                None,
                *saved_inputmats,
                *saved_inputmats_t,
                *weights,
                *weights_fp8,
                *[
                    w.main_grad if (cpu_offloading or fine_grained_activation_offloading) and fuse_wgrad_accumulation else None
                    for w in weights
                ],
            )
            ctx.m_splits = m_splits
            ctx.num_gemms = num_gemms
            ctx.activation_dtype = activation_dtype
            ctx.fp8 = fp8
            ctx.fp8_meta = fp8_meta
            ctx.fuse_wgrad_accumulation = fuse_wgrad_accumulation
            ctx.cpu_offloading = cpu_offloading
            ctx.is_first_microbatch = is_first_microbatch
            ctx.use_bias = use_bias
            ctx.sequence_parallel = sequence_parallel
            ctx.tensor_parallel = tensor_parallel
            ctx.inp_shape = inp.shape
            ctx.parallel_mode = parallel_mode
            ctx.tp_group = tp_group
            ctx.tp_size = tp_size
            ctx.requires_dgrad = inp.requires_grad
            ctx.reduce_and_update_bwd_fp8_tensors = False
            ctx.wgrad_store = wgrad_store
        # [*, in_features] -> [*, out_features] except first dimension changes for SP
        return out.view(-1, *inp.shape[1:-1], out.shape[-1])

    @staticmethod
    def backward(ctx, grad_output: torch.Tensor) -> Tuple[Union[torch.Tensor, None], ...]:
        logger = logging.getLogger("BatchLinear")

        with torch.cuda.nvtx.range("_BatchLinear_backward"):
            (
                fwd_scale_inverses,
                *saved_tensors,
            ) = ctx.saved_tensors
            inputmats = saved_tensors[: ctx.num_gemms]
            inputmats_t = saved_tensors[ctx.num_gemms : 2 * ctx.num_gemms]
            weights = saved_tensors[2 * ctx.num_gemms : 3 * ctx.num_gemms]
            weights_fp8 = saved_tensors[3 * ctx.num_gemms : 4 * ctx.num_gemms]
            main_grads = saved_tensors[4 * ctx.num_gemms :]
            if (ctx.cpu_offloading or ctx.fine_grained_activation_offloading) and ctx.fuse_wgrad_accumulation:
                for i in range(ctx.num_gemms):
                    w = torch.nn.Parameter(weights[i], weights[i].requires_grad)
                    w.main_grad = main_grads[i]
                    weights[i] = w
                    if ctx.fine_grained_activation_offloading and weights[i].requires_grad:
                        weights[i].grad_added_to_main_grad = ctx.grad_added_to_main_grad_list[i]

            global _GEMM_INPUT, _GEMM_WEIGHT, _GRAD_OUTPUT
            grad_output = grad_output.contiguous()
            grad_output_mats = torch.split(
                grad_output.view(-1, grad_output.shape[-1]), ctx.m_splits
            )
            grad_output_c = [None] * ctx.num_gemms
            grad_output_t = [None] * ctx.num_gemms
            grad_biases = [None] * ctx.num_gemms

            if ctx.is_first_microbatch is not None:
                accumulate_wgrad_into_param_main_grad = (
                    ctx.fuse_wgrad_accumulation and not ctx.is_first_microbatch
                )
            else:
                accumulate_wgrad_into_param_main_grad = ctx.fuse_wgrad_accumulation

            if ctx.requires_dgrad:
                logger.debug("Running backward in %s", ctx.activation_dtype)
                dgrad = torch.empty(
                    (sum(ctx.m_splits), int(weights[0].size(1))),
                    dtype=ctx.activation_dtype,
                    device=grad_output.device,
                )
                batchgemm(
                    weights,
                    grad_output_mats,
                    torch.split(dgrad, ctx.m_splits),
                    ctx.activation_dtype,
                    get_multi_stream_cublas_batchgemm_workspace(),
                    layout="NN",
                    grad=True,
                )

            if weights[0].requires_grad:
                if ctx.fuse_wgrad_accumulation:
                    wgrad_list = [w.main_grad for w in weights]
                else:
                    wgrad_list = [
                        torch.empty(w.size(), dtype=ctx.activation_dtype, device=w.device)
                        for w in weights
                    ]
                batched_gemm_wgrad = functools.partial(
                    batchgemm,
                    dtype=ctx.activation_dtype,
                    workspaces=get_multi_stream_cublas_batchgemm_workspace(),
                    layout="NT",
                    grad=True,
                    use_bias=ctx.use_bias,
                    accumulate=accumulate_wgrad_into_param_main_grad,
                )
                # WGRAD
                if ctx.wgrad_store is not None and ctx.wgrad_store.delay_wgrad_compute():
                    ctx.wgrad_store.put([inputmats, grad_output_mats, wgrad_list], batched_gemm_wgrad)
                else:
                    _, grad_biases_, _ = batched_gemm_wgrad(inputmats, grad_output_mats, wgrad_list)

                    for i in range(ctx.num_gemms):
                        if grad_biases[i] is None:
                            grad_biases[i] = grad_biases_[i]
                    del grad_biases_

                    # Deallocate input tensor
                    clear_tensor_data(*inputmats)
                    clear_tensor_data(*inputmats_t)

                def handle_custom_ddp_from_mcore(w, wgrad):
                    if w.requires_grad:
                        if ctx.fuse_wgrad_accumulation and hasattr(w, "grad_added_to_main_grad"):
                            w.grad_added_to_main_grad = True
                            if getattr(w, "zero_out_wgrad", False):
                                wgrad = get_dummy_wgrad(
                                    list(w.main_grad.shape),
                                    w.dtype,
                                    zero=True,
                                )
                            else:
                                wgrad = get_dummy_wgrad(
                                    list(w.main_grad.shape),
                                    w.dtype,
                                )
                        elif ctx.fuse_wgrad_accumulation:
                            wgrad = None
                    else:
                        wgrad = None
                    return wgrad
        
                wgrad_list = [
                    handle_custom_ddp_from_mcore(w, wgrad) for w, wgrad in zip(weights, wgrad_list)
                ]
            else:
                wgrad_list = [None] * ctx.num_gemms

            if ctx.wgrad_store is not None and ctx.wgrad_store.delay_wgrad_compute():
                # overlap_grad_reduce, dongcl
                if int(os.getenv("NVTE_OVERLAP_GRAD_REDUCE", "0")):
                    wgrad_list = [
                        torch.empty(w.size(), dtype=ctx.activation_dtype, device=w.device)
                        for w in weights
                    ]
                else:
                    wgrad_list = [None] * ctx.num_gemms

            if not ctx.use_bias or (
                ctx.wgrad_store is not None
                and ctx.wgrad_store.delay_wgrad_compute()
                and not ctx.fp8
            ):
                grad_biases = [None] * ctx.num_gemms

        if ctx.reduce_and_update_bwd_fp8_tensors and not is_graph_capturing():
            FP8GlobalStateManager.reduce_and_update_fp8_tensors(forward=False)

        return (
            dgrad.view(ctx.inp_shape) if ctx.requires_dgrad else None,
            None,  # m_splits
            None,  # use_bias
            None,  # is_first_microbatch
            None,  # fp8
            None,  # fp8_calibration
            None,  # wgrad_store
            None,  # fp8_meta
            None,  # fuse_wgrad_accumulation
            None,  # cpu_offloading
            None,  # tp_group
            None,  # tp_size
            None,  # sequence_parallel
            None,  # tensor_parallel
            None,  # activation_dtype
            None,  # parallel_mode
            None,  # is_grad_enabled
            None,  # fine_grained_activation_offloading
            *wgrad_list,
            *([None] * ctx.num_gemms),  # weights_fp8
            *grad_biases,
        )



class BatchedLinear(TransformerEngineBaseModule):
    """Applies linear transformations to the incoming data list
       :math:`y_i = x_iA_i^T + b_i` in a batched way.

    Parameters
    ----------
    num_gemms : int
                number of GEMMs to be performed simutaneously.
    in_features : int
                 size of each input sample.
    out_features : int
                  size of each output sample.
    bias : bool, default = `True`
          if set to `False`, the layer will not learn an additive bias.
    init_method : Callable, default = `None`
                 used for initializing weights in the following way: `init_method(weight)`.
                 When set to `None`, defaults to `torch.nn.init.normal_(mean=0.0, std=0.023)`.
    get_rng_state_tracker : Callable, default = `None`
                 used to get the random number generator state tracker for initilizeing weights.
    rng_tracker_name : str, default = `None`
                 the param passed to get_rng_state_tracker to get the specific rng tracker.
    device : Union[torch.device, str], default = "cuda"
          The device on which the parameters of the model will allocated. It is the user's
          responsibility to ensure all parameters are moved to the GPU before running the
          forward pass.

    Parallelism parameters
    ----------------------
    sequence_parallel : bool, default = `False`
                       if set to `True`, uses sequence parallelism.
    tp_group : ProcessGroup, default = `None`
              tensor parallel process group.
    tp_size : int, default = 1
             used as TP (tensor parallel) world size when TP groups are not formed during
             initialization. In this case, users must call the
             `set_tensor_parallel_group(tp_group)` method on the initialized module before the
             forward pass to supply the tensor parallel group needed for tensor and sequence
             parallel collectives.
    parallel_mode : {None, 'Column', 'Row'}, default = `None`
                   used to decide whether this BatchedLinear layer is Column Parallel Linear or Row
                   Parallel Linear as described `here <https://arxiv.org/pdf/1909.08053.pdf>`_.
                   When set to `None`, no communication is performed.

    Optimization parameters
    -----------------------
    fuse_wgrad_accumulation : bool, default = 'False'
                             if set to `True`, enables fusing of creation and accumulation of
                             the weight gradient. When enabled, it is assumed that the weights
                             have an additional `main_grad` attribute (used instead of the
                             regular `grad`) which is a pre-allocated buffer of the correct
                             size to accumulate gradients in.
    return_bias : bool, default = `False`
                 when set to `True`, this module will not apply the additive bias itself, but
                 instead return the bias value during the forward pass together with the
                 output of the linear transformation :math:`y = xA^T`. This is useful when
                 the bias addition can be fused to subsequent operations.
    params_dtype : torch.dtype, default = `torch.get_default_dtype()`
                  it controls the type used to allocate the initial parameters. Useful when
                  the model is trained with lower precision and the original FP32 parameters
                  would not fit in GPU memory.
    delay_wgrad_compute : bool, default = `False`
                         Whether to delay weight gradient computation

    """
    def __init__(
        self,
        num_gemms: int,
        in_features: int,
        out_features: int,
        sequence_parallel: bool = False,
        fuse_wgrad_accumulation: bool = False,
        tp_group: Optional[dist_group_type] = None,
        tp_size: int = 1,
        get_rng_state_tracker: Optional[Callable] = None,
        rng_tracker_name: Optional[str] = None,
        init_method: Optional[Callable] = None,
        bias: bool = True,
        return_bias: bool = False,
        params_dtype: Optional[torch.dtype] = None,
        parallel_mode: Optional[str] = None,
        device: Union[torch.device, str] = "cuda",
        ub_overlap_rs: bool = False,
        ub_overlap_ag: bool = False,
        fine_grained_activation_offloading: bool = False,
        ub_name: Optional[str] = None,
        delay_wgrad_compute: bool = False,
    ) -> None:
        super().__init__()

        params_dtype = torch.get_default_dtype() if params_dtype is None else params_dtype
        self.batch_num = int(os.getenv("NVTE_MOE_BATCHCOUNT", "2"))
        assert num_gemms % self.batch_num == 0, "Number of GEMMs should be batch_num multiply."
        self.num_gemms = num_gemms // self.batch_num
        self.in_features = in_features
        self.out_features = out_features
        self.fuse_wgrad_accumulation = fuse_wgrad_accumulation
        self.use_bias = bias
        self.return_bias = return_bias
        self.apply_bias = bias and not return_bias
        self.ub_overlap_rs = ub_overlap_rs
        self.ub_overlap_ag = ub_overlap_ag
        if ub_overlap_rs or ub_overlap_ag:
            assert ub_name is not None, "Userbuffer name [string] is not set."
        self.ub_name = ub_name
        self.get_rng_state_tracker = get_rng_state_tracker
        self.rng_tracker_name = rng_tracker_name

        self.fine_grained_activation_offloading = fine_grained_activation_offloading

        self.wgrad_store = WeightGradStore(delay_wgrad_compute)

        global _GEMM_INPUT, _GEMM_WEIGHT, _GEMM_OUTPUT
        _GEMM_INPUT, _GEMM_WEIGHT, _GEMM_OUTPUT = 0, self.num_gemms, 2 * self.num_gemms
        
        if tp_group is None:
            self.tp_size = tp_size
            if tp_size == 1:
                self.set_tensor_parallel_group(tp_group)
        else:
            self.tp_size = get_distributed_world_size(tp_group)
            self.set_tensor_parallel_group(tp_group)
        self.set_nccl_overlap_warning_if_tp()

        self.parallel_mode = parallel_mode
        assert (
            self.parallel_mode in GemmParallelModes
        ), f"parallel_mode {parallel_mode} not supported"

        if self.parallel_mode == "column":
            self.out_features = divide(self.out_features, self.tp_size)
        elif self.parallel_mode == "row":
            self.in_features = divide(self.in_features * self.batch_num, self.tp_size)

        self.sequence_parallel = (self.tp_size > 1) and sequence_parallel
        # In batchgemm, we use batch=batch_num to launch blas batchgemm 
        for i in range(int(self.num_gemms)):
            # Construct weight parameter
            self.register_parameter(
                f"weight{i}",
                torch.nn.Parameter(
                    torch.empty(
                        self.out_features * self.batch_num,
                        self.in_features,
                        device=device,
                        dtype=params_dtype,
                    ),
                ),
                init_fn=init_method,
                get_rng_state_tracker=get_rng_state_tracker,
                fp8_meta_index=_GEMM_WEIGHT + i,
            )

            # Construct bias parameters if needed
            if self.use_bias:
                self.register_parameter(
                    f"bias{i}",
                    torch.nn.Parameter(
                        torch.empty(
                            self.out_features * self.batch_num,
                            device=device,
                            dtype=params_dtype,
                        ),
                    ),
                    init_fn=init_method_constant(0.0),
                )
            else:
                bias = torch.Tensor().to(dtype=params_dtype, device=device)
                setattr(self, f"bias{i}", bias)
                
        if self.primary_weights_in_fp8:
            self.init_fp8_metadata(num_gemms=self.num_gemms)

        if self.primary_weights_in_fp8:
            self.init_fp8_metadata(num_gemms=self.num_gemms)

        self.reset_parameters(defer_init=(device == "meta"))
        

        # For RPL, bias has to be added after TP collectives
        # So it cannot be fused with the GEMM
        if self.parallel_mode == "row" and self.apply_bias:
            self.gemm_bias_unfused_add = True
        else:
            self.gemm_bias_unfused_add = False

    def reset_parameters(self, defer_init=False):
        super().reset_parameters(defer_init=defer_init)

        if not defer_init:
            # Set parallelism attributes for linear weights
            for i in range(int(self.num_gemms)):
                set_tensor_model_parallel_attributes(
                    tensor=getattr(self, f"weight{i}"),
                    is_parallel=True,
                    dim=1 if self.parallel_mode == "row" else 0,
                    stride=1,
                )

            # Set parallelism attributes for linear biases
            if self.use_bias:
                for i in range(self.num_gemms):
                    if self.parallel_mode == "row":
                        setattr(
                            getattr(self, f"bias{i}"),
                            "sequence_parallel",
                            self.sequence_parallel,
                        )
                    elif self.parallel_mode == "column":
                        set_tensor_model_parallel_attributes(getattr(self, f"bias{i}"), True, 0, 1)

    @no_torch_dynamo()
    def forward(
        self,
        inp: torch.Tensor,
        m_splits: List[int],
        is_first_microbatch: Optional[bool] = None,
    ) -> Union[torch.Tensor, Tuple[torch.Tensor, ...]]:
        """
        Apply the linear transformation to the input.

        Parameters
        ----------
        inp : torch.Tensor
             Input tensor.
        m_splits : List[int]
                 List of integers representing the split of the input tensor.
        is_first_microbatch : {True, False, None}, default = None
                             During training using either gradient accumulation or
                             pipeline parallelism a minibatch of data is further split
                             into microbatches. Between the microbatches of the same minibatch
                             the model weights are not updated. Setting this parameter indicates
                             whether the current microbatch is the first in a minibatch or not.
                             When set, this parameter enables additional optimizations:

                             * during FP8 training, it allows caching of the FP8 versions of
                               the weights
                             * it also allows skipping gradient accumulation during the
                               first microbatch (since it is the first gradient being
                               produced)
        """

        assert not isinstance(
            inp, Float8Tensor
        ), "BatchedLinear doesn't support input tensor in FP8."
        m_splits_batch_gemm = [x * self.batch_num for x in m_splits[0:int(self.num_gemms)]]
        assert len(m_splits_batch_gemm) == self.num_gemms, "Number of splits should match number of GEMMs."
        skip_fp8_weight_update = FP8GlobalStateManager.get_skip_fp8_weight_update_tensor()
        if skip_fp8_weight_update is not None:
            is_first_microbatch = False
        with self.prepare_forward(inp=inp, num_gemms=self.num_gemms) as inp:

            weight_tensors = [getattr(self, f"weight{i}") for i in range(int(self.num_gemms))]
            bias_tensors = [getattr(self, f"bias{i}") for i in range(int(self.num_gemms))]
            if not self.fp8:
                weight_tensors = [
                    w.from_float8() if isinstance(w, Float8Tensor) else w for w in weight_tensors
                ]

            weight_tensors_fp8 = [None] * int(self.num_gemms)

            from ..cpu_offload import CPUOffloadEnabled

            if torch.is_grad_enabled():
                linear_fn = _BatchLinear.apply
                args = []
            else:
                linear_fn = _BatchLinear.forward
                args = [None]
            args += (
                inp,
                m_splits_batch_gemm,
                self.apply_bias and not self.gemm_bias_unfused_add,
                is_first_microbatch,
                self.fp8,
                self.fp8_calibration,
                self.wgrad_store,
                self.fp8_meta,
                self.fuse_wgrad_accumulation,
                CPUOffloadEnabled,
                self.tp_group,
                self.tp_size,
                self.sequence_parallel,
                self.tp_size > 1,
                self.activation_dtype,
                self.parallel_mode,
                torch.is_grad_enabled(),
                self.fine_grained_activation_offloading,
                *weight_tensors,
                *weight_tensors_fp8,
                *bias_tensors,
            )
            out = linear_fn(*args)
            
        if self.gemm_bias_unfused_add:
            out_shape = out.shape
            out = torch.cat(
                [
                    o + cast_if_needed(b, self.activation_dtype)
                    for o, b in zip(
                        torch.split(out.view(-1, self.out_features), m_splits_batch_gemm), bias_tensors
                    )
                ]
            ).view(out_shape)
        if self.return_bias:
            return out, [cast_if_needed(b, self.activation_dtype) for b in bias_tensors]
        return out
    
    def backward_dw(self):
        """
        Execute the delayed weight gradient computation.
        This method is called after the main backward pass to compute weight gradients.
        """
        if self.wgrad_store is None or not self.wgrad_store.delay_wgrad_compute():
            return
        with torch.cuda.nvtx.range("_GroupedLinear_wgrad"):
            (_, grad_biases_, _), tensor_list = self.wgrad_store.pop()
            wgrad_list = tensor_list[2]
            if not self.fuse_wgrad_accumulation:
                for i in range(self.num_gemms):
                    weight_param = getattr(self, f"weight{i}")
                    if weight_param.grad is None:
                        weight_param.grad = wgrad_list[i].to(weight_param.dtype)
            if self.use_bias:
                for i in range(self.num_gemms):
                    bias_param = getattr(self, f"bias{i}")
                    if bias_param.grad is None:
                        bias_param.grad = grad_biases_[i].to(bias_param.dtype)
            del grad_biases_
            del wgrad_list
            del tensor_list
