You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tvm.apache.org by ju...@apache.org on 2023/07/18 21:58:38 UTC

[tvm] 02/07: [Unity][Training] Enhance gradient system (#15230)

This is an automated email from the ASF dual-hosted git repository.

junrushao pushed a commit to branch unity-staging
in repository https://gitbox.apache.org/repos/asf/tvm.git

commit 98ef29a852813e9639bef98138811841ca385955
Author: Yixin Dong <ub...@gmail.com>
AuthorDate: Sun Jul 16 22:08:32 2023 +0800

    [Unity][Training] Enhance gradient system (#15230)
    
    This PR adds these features to the gradient system:
    - Checkpointing for gradient pass
        - `tvm.relax.testing.nn.checkpoint`
        - `tvm.relax.op.grad.start_checkpoint` and `tvm.relax.op.grad.start_checkpoint`
        - Support in the Gradient pass
    - Fix several minor problems in op_gradient
---
 python/tvm/relax/op/__init__.py                    |   2 +-
 python/tvm/relax/op/_op_gradient.py                |  35 +-
 python/tvm/relax/op/grad/grad.py                   |  53 ++
 python/tvm/relax/testing/nn.py                     | 123 ++-
 python/tvm/relax/training/utils.py                 |  14 +-
 python/tvm/relax/transform/legalize_ops/grad.py    |  15 +-
 python/tvm/relax/transform/transform.py            |   4 +
 src/relax/op/tensor/grad.cc                        |  47 +-
 src/relax/transform/gradient.cc                    | 573 ++++++++++---
 tests/python/relax/test_op_grad.py                 |  45 +
 tests/python/relax/test_op_gradient_numeric.py     |   4 +-
 tests/python/relax/test_training_setup_trainer.py  |  25 +-
 tests/python/relax/test_transform_gradient.py      | 949 +++++++++++----------
 .../relax/test_transform_gradient_checkpoint.py    | 689 +++++++++++++++
 ...t.py => test_transform_gradient_te_register.py} | 152 +++-
 15 files changed, 2075 insertions(+), 655 deletions(-)

diff --git a/python/tvm/relax/op/__init__.py b/python/tvm/relax/op/__init__.py
index d30ced89ec..4fdca46e8d 100644
--- a/python/tvm/relax/op/__init__.py
+++ b/python/tvm/relax/op/__init__.py
@@ -39,7 +39,7 @@ from . import image
 from . import memory
 from . import nn
 
-# Operator gradient functions
+# Register operator gradient functions
 from . import _op_gradient
 
 
diff --git a/python/tvm/relax/op/_op_gradient.py b/python/tvm/relax/op/_op_gradient.py
index d57bcc8621..17bafe0a37 100644
--- a/python/tvm/relax/op/_op_gradient.py
+++ b/python/tvm/relax/op/_op_gradient.py
@@ -87,14 +87,14 @@ def _get_dtype(expr: Expr) -> str:
     return dtype
 
 
-def _fit_shape(bb: BlockBuilder, expr: Expr, target: Expr) -> Expr:
+def _fit_shape(bb: BlockBuilder, input_grad: Expr, input: Expr) -> Expr:
     """When expr and target has the same shape, return expr;
     otherwise return `collapse_sum_to(expr, target.struct_info.shape)`.
 
     Will use BlockBuilder to normalize expr first.
     """
-    target_shape = _get_shape(target)
-    expr_sinfo = _get_shape(bb.normalize(expr)).struct_info
+    target_shape = _get_shape(input)
+    expr_sinfo = _get_shape(bb.normalize(input_grad)).struct_info
     target_sinfo = target_shape.struct_info
     assert isinstance(expr_sinfo, ShapeStructInfo)
     assert isinstance(target_sinfo, ShapeStructInfo)
@@ -109,9 +109,9 @@ def _fit_shape(bb: BlockBuilder, expr: Expr, target: Expr) -> Expr:
         return True
 
     return (
-        expr
+        input_grad
         if _check_shape_equal(expr_sinfo, target_sinfo)
-        else collapse_sum_to(expr, target_shape)
+        else collapse_sum_to(input_grad, target_shape)
     )
 
 
@@ -250,15 +250,14 @@ def maximum_grad(
         `z = relax.maximum(x, y)`
 
     Backward:
-        Returns `[z_grad * (where(x < y, 0, 1)), z_grad * (where(x >= y, 0, 1))]`.
+        Returns `[where(x < y, 0, z_grad), where(x >= y, 0, z_grad)]`.
     """
     x = orig_call.args[0]
     y = orig_call.args[1]
-    one = relax.const(1, _get_dtype(x))
     zero = relax.const(0, _get_dtype(x))
     return [
-        where(less(x, y), zero, one) * output_grad,
-        where(greater_equal(x, y), zero, one) * output_grad,
+        where(less(x, y), zero, output_grad),
+        where(greater_equal(x, y), zero, output_grad),
     ]
 
 
@@ -275,15 +274,14 @@ def minimum_grad(
         `z = relax.minimum(x, y)`
 
     Backward:
-        Returns `[z_grad * (where(x >= y, 0, 1)), z_grad * (where(x < y, 0, 1))]`.
+        Returns `[where(x >= y, 0, z_grad), where(x < y, 0, z_grad)]`.
     """
     x = orig_call.args[0]
     y = orig_call.args[1]
-    one = relax.const(1, _get_dtype(x))
     zero = relax.const(0, _get_dtype(x))
     return [
-        where(greater_equal(x, y), zero, one) * output_grad,
-        where(less(x, y), zero, one) * output_grad,
+        where(greater_equal(x, y), zero, output_grad),
+        where(less(x, y), zero, output_grad),
     ]
 
 
@@ -1030,12 +1028,11 @@ def relu_grad(
         `y = relax.relu(x)`
 
     Backward:
-        Returns `[y_grad * (where(x < 0, 0, 1))]`.
+        Returns `[where(x < 0, 0, y_grad)]`.
     """
     x = orig_call.args[0]
-    one = relax.const(1, _get_dtype(x))
     zero = relax.const(0, _get_dtype(x))
-    return [where(less(x, zero), zero, one) * output_grad]
+    return [where(less(x, zero), zero, output_grad)]
 
 
 @register_gradient("relax.nn.silu")
@@ -1090,10 +1087,10 @@ def log_softmax_grad(
         `y = relax.log_softmax(x, axis)`
 
     Backward:
-        Returns `[y_grad - sum(y_output_grad, axis, keepdims=True) * softmax(x)]`
+        Returns `[y_grad - sum(y_grad, axis, keepdims=True) * exp(y)]`
     """
-    x_softmax = exp(orig_var)
-    return [(output_grad - sum(output_grad, orig_call.attrs.axis, True) * x_softmax)]
+    y_exp = exp(orig_var)
+    return [(output_grad - sum(output_grad, orig_call.attrs.axis, True) * y_exp)]
 
 
 @register_gradient("relax.nn.cross_entropy_with_logits")
diff --git a/python/tvm/relax/op/grad/grad.py b/python/tvm/relax/op/grad/grad.py
index e1f1591876..2218db2232 100644
--- a/python/tvm/relax/op/grad/grad.py
+++ b/python/tvm/relax/op/grad/grad.py
@@ -43,6 +43,59 @@ def no_grad(input: Expr) -> Expr:
     return _ffi_api.no_grad(input)  # type: ignore
 
 
+def start_checkpoint(input: Expr) -> Expr:
+    """Mark the start of the checkpoint stage. The computation between start_checkpoint and
+    end_checkpoint will be marked as the checkpoint stage.
+
+    Rather than storing all intermediate activations of the entire computation graph for
+    computing backward, the checkpointed stage does not save intermediate activations, and instead
+    recomputes them in backward process.
+
+    For instance,
+    ```
+    a = relax.Var("a", relax.TensorStructInfo((2, 2), "float32"))
+    b = relax.Var("b", relax.TensorStructInfo((2, 2), "float32"))
+    c = a * 2
+    d = b * 2
+    c_cp = start_checkpoint(c)
+    d_cp = start_checkpoint(d)
+    e = c_cp + d_cp
+    e_out = end_checkpoint(e)
+    ```
+    Then `e` will be recomputed in the backward stage.
+
+    See tvm.relax.transform.Gradient, tvm.relax.testing.nn.checkpoint,
+    tvm.relax.op.grad.end_checkpoint for more information.
+
+    Parameters
+    ----------
+    input : relax.Expr
+      The tensor marking the input of the checkpoint stage.
+
+    Returns
+    -------
+    result : relax.Expr
+      The same tensor as the input.
+    """
+    return _ffi_api.start_checkpoint(input)  # type: ignore
+
+
+def end_checkpoint(input: Expr) -> Expr:
+    """Mark the end of checkpoint stage. See tvm.relax.op.grad.start_checkpoint.
+
+    Parameters
+    ----------
+    input : relax.Expr
+      The output of the checkpoint stage.
+
+    Returns
+    -------
+    result : relax.Expr
+      The same tensor as the input.
+    """
+    return _ffi_api.end_checkpoint(input)  # type: ignore
+
+
 def nll_loss_backward(
     output_grad: Expr,
     predictions: Expr,
diff --git a/python/tvm/relax/testing/nn.py b/python/tvm/relax/testing/nn.py
index a43dfab56e..ba7ca2653f 100644
--- a/python/tvm/relax/testing/nn.py
+++ b/python/tvm/relax/testing/nn.py
@@ -14,7 +14,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-# pylint: disable=redefined-builtin
+# pylint: disable=redefined-builtin, invalid-name
 """PyTorch-like nn.Module API for constructing workloads."""
 
 
@@ -24,6 +24,7 @@ import numpy as np  # type: ignore
 
 import tvm
 from tvm import relax, topi, tir
+from tvm.relax.op.grad.grad import end_checkpoint, start_checkpoint
 
 
 def emit(expr: relax.Expr, name_hint: str = "") -> relax.Var:
@@ -34,6 +35,126 @@ def emit_te(func: Callable, *args: Any, **kwargs: Any) -> relax.Var:
     return relax.BlockBuilder.current().emit_te(func, *args, **kwargs)
 
 
+def checkpoint(
+    func: Callable, *args: Any, **kwargs: Any
+) -> Union[relax.Var, List[relax.Var], List[Any]]:
+    """Mark function(*args, **kwargs) should be computed in a checkpointed manner during backward.
+
+    To be specific, args and kwargs will be checkpointed, and func(*args, **kwargs) will be
+    recomputed in the backward stage.
+    """
+    args = [start_checkpoint(v) if isinstance(v, relax.Expr) else v for v in args]
+    kwargs = {k: start_checkpoint(v) if isinstance(v, relax.Expr) else v for k, v in kwargs.items()}
+    result = func(*args, **kwargs)
+    if isinstance(result, (list, tuple)):
+        result = [end_checkpoint(v) if isinstance(v, relax.Expr) else v for v in result]
+    else:
+        assert isinstance(result, relax.Expr)
+        result = end_checkpoint(result)
+    return result
+
+
+def emit_checkpoint(
+    func: Callable, *args: Any, **kwargs: Any
+) -> Union[relax.Var, List[relax.Var], List[Any]]:
+    """Mark function(*args, **kwargs) should be computed in a checkpointed manner during backward.
+
+    To be specific, args and kwargs will be checkpointed, and func(*args, **kwargs) will be
+    recomputed in the backward stage.
+
+    This interface will additionally emit the exprs marked with start_checkpoint() and
+    end_checkpoint() with suffix "_scp" and "_ecp" respectively, for easily understanding the
+    result tvmscript.
+    """
+    bb = relax.BlockBuilder.current()
+    args = [
+        bb.emit(start_checkpoint(v), v.name_hint + "_scp") if isinstance(v, relax.Var) else v
+        for v in args
+    ]
+    kwargs = {
+        k: bb.emit(start_checkpoint(v), v.name_hint + "_scp") if isinstance(v, relax.Var) else v
+        for k, v in kwargs.items()
+    }
+    result = func(*args, **kwargs)
+    if isinstance(result, (list, tuple)):
+        result = list(result)
+        for i, v in enumerate(result):
+            if isinstance(v, relax.Expr):
+                if not isinstance(v, relax.Var):
+                    v = bb.emit(v)
+                result[i] = bb.emit(end_checkpoint(v), v.name_hint + "_ecp")
+    else:
+        assert isinstance(result, relax.Expr)
+        result_emit = bb.emit(result)
+        result = bb.emit(end_checkpoint(result_emit), result_emit.name_hint + "_ecp")
+
+    return result
+
+
+def emit_checkpoint_sequential(
+    functions: List[Callable],
+    segments: Union[int, List[int]],
+    input: relax.Var,
+    checkpoint_last: bool = False,
+) -> Union[relax.Var, List[relax.Var], List[Any]]:
+    """A helper function for checkpointing sequential models. This interface has similar purpose
+    as torch.utils.checkpoint.checkpoint_sequential.
+
+    Sequential models execute a list of modules/functions in order (sequentially). Therefore, we
+    can divide such a model in various segments and checkpoint each segment. By default, we will
+    checkpoint all segments except the last, meaning their inputs will be saved from the forward
+    stage and they will be recomputed in the backward stage.
+
+    Parameters
+    ----------
+    functions : List[Callable]
+        The list of functions to be executed sequentially.
+
+    segments : Union[int, List[int]]
+        The segments. If segments is int `n`, functions will be evenly divided into `n` segments;
+        if segments is a list of ints, it marks the start of every segment.
+
+    input : relax.Var
+        The input of the first function.
+
+    checkpoint_last : bool
+        Whether the last segment will be checkpointed. Default: False
+
+    Returns
+    -------
+    output : Union[relax.Var, List[relax.Var], List[Any]]
+        The emited output of the last function.
+    """
+    bb = relax.BlockBuilder.current()
+
+    def run_function(start, end, functions):
+        def forward(input):
+            for j in range(start, end):
+                input = functions[j](input)
+            return input
+
+        return forward
+
+    n = len(functions)
+    if not isinstance(segments, list):
+        segments = list(range(0, n, n // segments)) + [n]
+    if segments[-1] != n:
+        segments = segments + [n]
+
+    assert len(segments) >= 2
+
+    for i in range(len(segments) - 1):
+        if i == len(segments) - 2 and not checkpoint_last:
+            input = run_function(segments[i], segments[i + 1], functions)(input)
+        else:
+            input = emit_checkpoint(run_function(segments[i], segments[i + 1], functions), input)
+
+    assert isinstance(input, relax.Expr)
+    if not isinstance(input, relax.Var):
+        input = bb.emit(input)
+    return input
+
+
 def _try_unique_name(name: str):
     """Attempt to uniquify the name
 
diff --git a/python/tvm/relax/training/utils.py b/python/tvm/relax/training/utils.py
index 69fa85043e..bf9e937457 100644
--- a/python/tvm/relax/training/utils.py
+++ b/python/tvm/relax/training/utils.py
@@ -14,7 +14,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-# pylint: disable=invalid-name
+# pylint: disable=invalid-name, unused-argument
 """Utility functions for relax training."""
 
 from typing import Optional, Callable
@@ -24,7 +24,7 @@ from tvm import relax
 from tvm._ffi.registry import register_func
 from tvm.relax.block_builder import BlockBuilder
 
-from ..expr import Function
+from ..expr import Function, Var, Call
 from . import _ffi_api
 
 
@@ -189,13 +189,13 @@ def register_te_gradient(te_grad_name: str, te_grad_func: Callable = None):
         # It will return the emitted var.
 
         def handler(
-            builder: BlockBuilder, output_grad_var: relax.Var, call_tir: relax.Call
+            orig_var: Var, call_tir_with_grad: Call, output_grad: Var, ctx: BlockBuilder
         ) -> relax.Expr:
-            return builder.emit_te(
+            return ctx.emit_te(
                 func,
-                output_grad_var,
-                *call_tir.args[1],
-                **call_tir.attrs.te_grad_kwargs,
+                output_grad,
+                *call_tir_with_grad.args[1],
+                **call_tir_with_grad.attrs.te_grad_kwargs,
                 primfunc_name_hint=te_grad_name,
             )
 
diff --git a/python/tvm/relax/transform/legalize_ops/grad.py b/python/tvm/relax/transform/legalize_ops/grad.py
index f5c295afc6..1d527bea6a 100644
--- a/python/tvm/relax/transform/legalize_ops/grad.py
+++ b/python/tvm/relax/transform/legalize_ops/grad.py
@@ -29,6 +29,16 @@ def _no_grad(bb: BlockBuilder, call: Call) -> Expr:
     return call.args[0]
 
 
+@register_legalize("relax.grad.start_checkpoint")
+def _start_checkpoint(bb: BlockBuilder, call: Call) -> Expr:
+    return call.args[0]
+
+
+@register_legalize("relax.grad.end_checkpoint")
+def _end_checkpoint(bb: BlockBuilder, call: Call) -> Expr:
+    return call.args[0]
+
+
 @register_legalize("relax.grad.nll_loss_backward")
 def _grad_nll_loss_backward(bb: BlockBuilder, call: Call) -> Expr:
     # topi.sum don't support zero-dim x
@@ -51,9 +61,8 @@ def _grad_nll_loss_backward(bb: BlockBuilder, call: Call) -> Expr:
         if reduction == "sum":
             output_grad = topi.broadcast_to(output_grad, targets.shape)
         elif reduction == "mean":
-            output_grad = topi.divide(
-                topi.broadcast_to(output_grad, targets.shape), topi_sum_extend(all_weights)
-            )
+            weight_sum = topi_sum_extend(all_weights)
+            output_grad = topi.divide(topi.broadcast_to(output_grad, targets.shape), weight_sum)
 
         # handle no batch
         if predictions.ndim == 1:
diff --git a/python/tvm/relax/transform/transform.py b/python/tvm/relax/transform/transform.py
index d8d11a50d8..d1d337a8c0 100644
--- a/python/tvm/relax/transform/transform.py
+++ b/python/tvm/relax/transform/transform.py
@@ -75,6 +75,10 @@ def Gradient(
                 R.output(original_outputs, grad_1, grad_2, ...)
             return (original_return_value, (grad_1, grad_2, ...))
 
+    This AD pass also supports checkpointing as described in
+    "Training deep nets with sublinear memory cost." - Chen, Tianqi, et al. (2016).
+    See tvm.relax.testing.nn.checkpoint for more details.
+
     Parameters
     ----------
     func_name : str
diff --git a/src/relax/op/tensor/grad.cc b/src/relax/op/tensor/grad.cc
index 2fef2d09b9..6f30684460 100644
--- a/src/relax/op/tensor/grad.cc
+++ b/src/relax/op/tensor/grad.cc
@@ -42,10 +42,55 @@ StructInfo InferStructInfoNoGrad(const Call& call, const BlockBuilder& ctx) {
 }
 
 TVM_REGISTER_OP("relax.grad.no_grad")
-    .set_num_inputs(0)
+    .set_num_inputs(1)
+    .add_argument("x", "Expr", "The corresponding input tensor.")
     .set_attr<FInferStructInfo>("FInferStructInfo", InferStructInfoNoGrad)
     .set_attr<Bool>("FPurity", Bool(true));
 
+/* relax.grad.start_checkpoint */
+Expr start_checkpoint(Expr input) {
+  static const Op& op = Op::Get("relax.grad.start_checkpoint");
+  return Call(op, {std::move(input)}, {}, {});
+}
+
+TVM_REGISTER_GLOBAL("relax.op.grad.start_checkpoint").set_body_typed(start_checkpoint);
+
+StructInfo InferStructInfoStartCheckpoint(const Call& call, const BlockBuilder& ctx) {
+  if (!call->args[0].as<VarNode>()) {
+    ctx->ReportFatal(Diagnostic::Error(call)
+                     << "The argument of relax.op.grad.start_checkpoint should be a Var.");
+  }
+  return GetStructInfo(call->args[0]);
+}
+
+TVM_REGISTER_OP("relax.grad.start_checkpoint")
+    .set_num_inputs(1)
+    .add_argument("x", "Expr", "The tensor marking the input of the checkpoint stage.")
+    .set_attr<FInferStructInfo>("FInferStructInfo", InferStructInfoStartCheckpoint)
+    .set_attr<Bool>("FPurity", Bool(true));
+
+/* relax.grad.end_checkpoint */
+Expr end_checkpoint(Expr input) {
+  static const Op& op = Op::Get("relax.grad.end_checkpoint");
+  return Call(op, {std::move(input)}, {}, {});
+}
+
+TVM_REGISTER_GLOBAL("relax.op.grad.end_checkpoint").set_body_typed(end_checkpoint);
+
+StructInfo InferStructInfoEndCheckpoint(const Call& call, const BlockBuilder& ctx) {
+  if (!call->args[0].as<VarNode>()) {
+    ctx->ReportFatal(Diagnostic::Error(call)
+                     << "The argument of relax.op.grad.end_checkpoint should be a Var.");
+  }
+  return GetStructInfo(call->args[0]);
+}
+
+TVM_REGISTER_OP("relax.grad.end_checkpoint")
+    .set_num_inputs(1)
+    .add_argument("x", "Expr", "The output of the checkpoint stage.")
+    .set_attr<FInferStructInfo>("FInferStructInfo", InferStructInfoEndCheckpoint)
+    .set_attr<Bool>("FPurity", Bool(true));
+
 /* relax.grad.nll_loss_backward */
 Expr nll_loss_backward(Expr output_grad, Expr predictions, Expr targets, Optional<Expr> weights,
                        String reduction, int ignore_index) {
diff --git a/src/relax/transform/gradient.cc b/src/relax/transform/gradient.cc
index 9a271a3ebe..4fd1838670 100644
--- a/src/relax/transform/gradient.cc
+++ b/src/relax/transform/gradient.cc
@@ -25,6 +25,7 @@
  * with respect to the only return value of the function, which needs to be scalar.
  */
 
+#include <tvm/relax/analysis.h>
 #include <tvm/relax/attrs/op.h>
 #include <tvm/relax/expr_functor.h>
 #include <tvm/relax/nested_msg.h>
@@ -40,10 +41,235 @@
 namespace tvm {
 namespace relax {
 
+// We will use NestedMsg<Expr> to handle adjoint updates involving tuple handling
 using AdjointMsg = NestedMsg<Expr>;
+using VarIdSet = std::unordered_set<Id, ObjectPtrHash, ObjectPtrEqual>;
 
-// A tool class for GradientMutator
-// Visit the forward bindings and generate the backward bindings
+// Used in CallTIRWithGradCollector. call_tir -> call_tir_with_grad
+using CallTIRWithGradInfo = std::unordered_map<Call, Call, ObjectPtrHash, ObjectPtrEqual>;
+
+/*!
+ * \brief Collect all call_tir_with_grad nodes, transform them into call_tir nodes, and collect the
+ * te_grad_name and te_grad_kwargs information.
+ */
+class CallTIRWithGradEliminator : private ExprMutator {
+ public:
+  /*!
+   * \brief Collect all variables that needs to be checkpointed, and remove the start_checkpoint
+   * and the end_checkpoint bindings.
+   *
+   * \param func The original function
+   * \return The function with all start_checkpoint and end_checkpoint bindings removed, and a
+   * VarIdSet containing all checkpointed vars.
+   */
+  static Function Transform(const Function& func) {
+    return Downcast<Function>(CallTIRWithGradEliminator().VisitExpr(func));
+  }
+
+ private:
+  using ExprMutator::VisitExpr_;
+
+  Expr VisitExpr_(const CallNode* call_node) final {
+    if (call_node->op != Op::Get("relax.call_tir_with_grad")) {
+      return ExprMutator::VisitExpr_(call_node);
+    }
+    return Call(Op::Get("relax.call_tir"), call_node->args, {}, call_node->sinfo_args,
+                call_node->span);
+  }
+};
+
+/*!
+ * \brief Collect all variables that needs to be checkpointed, and remove the start_checkpoint
+ * and the end_checkpoint bindings.
+ *
+ * Here we have some principles to determine which var should be checkpointed:
+ * 1. Input of the function is checkpointed
+ * 2. For var x marked with start_checkpoint() (wrapped by start_checkpoint), it means x is an input
+ *    to some checkpoint function. So var x is checkpointed
+ * 3. For other var x , find its predecessor path.
+ *   a. If every predecessor path is marked with end_checkpoint(), x is checkpointed
+ *   b. Else, there must exists a predecessor path marked with start_checkpoint(). So x is not
+ *      checkpointed
+ */
+class CheckpointCollector : private ExprMutator {
+ public:
+  /*!
+   * \brief Collect all variables that needs to be checkpointed, and remove the start_checkpoint
+   * and the end_checkpoint bindings.
+   *
+   * \param func The original function
+   * \return The function with all start_checkpoint and end_checkpoint bindings removed, and a
+   * VarIdSet containing all checkpointed vars.
+   */
+  static std::pair<Function, VarIdSet> Collect(const Function& func) {
+    auto collector = CheckpointCollector();
+    return std::make_pair(Downcast<Function>(collector.VisitExpr(func)), collector.checkpoints_);
+  }
+
+ private:
+  Expr VisitExpr_(const FunctionNode* func) final {
+    for (auto var : func->params) {
+      checkpoints_.insert(var->vid);
+    }
+
+    return ExprMutator::VisitExpr_(func);
+  }
+
+  void VisitBinding(const Binding& binding) {
+    static const auto s_cp = Op::Get("relax.grad.start_checkpoint");
+    static const auto e_cp = Op::Get("relax.grad.end_checkpoint");
+
+    // If every variable that the variable of binding relys on is either
+    // 1) the output of end_checkpoint; 2) checkpointed
+    // then the variable of binding will be checkpointed
+    auto var_binding = binding.as<VarBindingNode>();
+    ICHECK(var_binding);
+
+    auto value_call = var_binding->value.as<CallNode>();
+    if (!value_call || (value_call->op != s_cp && value_call->op != e_cp)) {
+      bool all_inner_var_checkpointed = true;
+      PostOrderVisit(var_binding->value, [this, &all_inner_var_checkpointed](const Expr& expr) {
+        if (auto var = expr.as<VarNode>()) {
+          all_inner_var_checkpointed &=
+              (checkpoints_.count(var->vid) != 0 || e_vars_.count(var->vid) != 0);
+        }
+      });
+
+      if (all_inner_var_checkpointed) {
+        checkpoints_.insert(var_binding->var->vid);
+      }
+    }
+
+    ExprMutator::VisitBinding(binding);
+  }
+
+  // mark vars to be checkpointed, and eliminate bindings with checkpoint calls
+  void VisitBinding_(const VarBindingNode* binding, const CallNode* value) final {
+    static const auto s_cp = Op::Get("relax.grad.start_checkpoint");
+    static const auto e_cp = Op::Get("relax.grad.end_checkpoint");
+
+    if (value->op == s_cp || value->op == e_cp) {
+      // Eliminate the binding
+      auto var = value->args[0].as<VarNode>();
+      ICHECK(var) << "The first argument of relax.grad.start_checkpoint and "
+                     "relax.grad.end_checkpoint should be a Var";
+      // var might already be remapped. Find the original var
+      auto orig_var = Downcast<Var>(ExprMutator::VisitExpr(GetRef<Var>(var)));
+      // Add remapping from binding->var to new_var
+      if (!binding->var.as<DataflowVarNode>() && var->IsInstance<DataflowVarNode>()) {
+        // For output binding, emit a dummy binding
+        this->var_remap_[binding->var->vid] = builder_->EmitOutput(orig_var, orig_var->name_hint());
+      } else {
+        this->var_remap_[binding->var->vid] = orig_var;
+      }
+
+      if (value->op == s_cp) {
+        // mark the original var to be checkpointed
+        checkpoints_.insert(orig_var->vid);
+      } else if (value->op == e_cp) {
+        e_vars_.insert(binding->var->vid);
+      }
+    } else {
+      ExprMutator::VisitBinding_(binding, value);
+    }
+  }
+
+  VarIdSet checkpoints_;
+  VarIdSet e_vars_;
+};
+
+/*!
+ * \brief A tool class for BackwardBindingGenerator
+ * Generate the checkpoint bindings. To be specific, in the backward process, we need to use vars
+ * computed in the forward process. Those vars contained in the given checkpoints array, and the
+ * inputs of the function, will be used as is; other vars will be computed again (this will
+ * generate bindings) using the checkpoint vars.
+ */
+class CheckpointGenerator : private ExprMutator {
+ public:
+  /*!
+   * \brief Generate the checkpoint bindings for BackwardBindingGenerator
+   *
+   * \param builder The BlockBuilder of BackwardBindingGenerator, used to generate bindings
+   * \param orig_params The parameters of the forward function
+   * \param forward_block The forward DataflowBlock
+   * \param checkpoints The checkpointed vars. checkpoints being empty means all Vars are
+   * checkpointed
+   */
+  CheckpointGenerator(const BlockBuilder& builder, const Array<Var>& orig_params,
+                      const DataflowBlock& forward_block, const VarIdSet& checkpoints)
+      : builder_(builder) {
+    // func params will always be checkpointed
+    for (auto var : orig_params) {
+      checkpoint_map_.Set(var, var);
+    }
+
+    for (auto binding : forward_block->bindings) {
+      auto* var_binding = binding.as<VarBindingNode>();
+      CHECK(var_binding) << "Now only support VarBindingNode";
+      auto var = var_binding->var;
+      binding_map_.Set(var, var_binding->value);
+      if (checkpoints.count(var->vid)) {
+        checkpoint_map_.Set(var, var);
+      }
+    }
+  }
+
+  // Receives the forward binding var and value, returns the checkpointed binding var and value.
+  std::pair<Var, Expr> UpdateBinding(const Var& var, const Expr& value) {
+    Expr new_value = VisitExpr(value);
+    auto it = checkpoint_map_.find(var);
+    if (it != checkpoint_map_.end()) {
+      return std::make_pair((*it).second, new_value);
+    }
+    auto new_var = builder_->Emit(new_value, var->name_hint() + "_cp");
+    checkpoint_map_.Set(var, new_var);
+    return std::make_pair(new_var, new_value);
+  }
+
+ private:
+  using ExprMutator::VisitExpr_;
+
+  // Visit the use-site of a defined Var
+  Expr VisitExpr_(const VarNode* op) final { return VisitVar(GetRef<Var>(op)); }
+
+  // Visit the use-site of a defined DataflowVar
+  Expr VisitExpr_(const DataflowVarNode* op) final { return VisitVar(GetRef<Var>(op)); }
+
+  Expr VisitVar(const Var& var) {
+    auto it = checkpoint_map_.find(var);
+    if (it != checkpoint_map_.end()) {
+      return (*it).second;
+    }
+    Var new_var = builder_->Emit(VisitExpr(binding_map_[var]), var->name_hint() + "_cp");
+    checkpoint_map_.Set(var, new_var);
+    return new_var;
+  }
+
+  // The only purpose of this function is create a new expr for Call node
+  // to pass the structual equal check
+  Expr VisitExpr_(const CallNode* call_node) final {
+    Expr new_op = this->VisitExpr(call_node->op);
+
+    tvm::Array<Expr> call_args;
+    for (Expr arg : call_node->args) {
+      Expr new_arg = this->VisitExpr(arg);
+      call_args.push_back(new_arg);
+    }
+    return Call(new_op, call_args, call_node->attrs, call_node->sinfo_args);
+  }
+
+  BlockBuilder builder_;
+  // The mapping from the forward vars to the checkpoint vars.
+  Map<Var, Var> checkpoint_map_;
+  // The mapping from the forward vars to their bindings, used to generate checkpoint bindings
+  Map<Var, Expr> binding_map_;
+};
+
+/*!
+ * \brief A tool class for GradientMutator
+ * Visit the forward bindings and generate the backward bindings
+ */
 class BackwardBindingGenerator : private ExprVisitor {
  public:
   /*!
@@ -52,23 +278,26 @@ class BackwardBindingGenerator : private ExprVisitor {
    * \param builder The BlockBuilder of GradientMutator, used to generate bindings
    * \param forward_block The forward DataflowBlock
    * \param require_grads The Var list to differentiate w.r.t.
+   * \param orig_params The params of the forward function. Used for checkpointing
    * \param target_var The target Var to differentiate
    * \param orig_return_value The original return value of the function. The new return value is a
-   * 2-tuple, containing the original return value, and a tuple of the adjoints of parameters.
+   * 2-tuple, containing the original return value, and a tuple of the adjoints of parameters
+   * \param checkpoints The checkpointed vars. checkpoints being empty means all Vars are
+   * checkpointed
    * \return The return expr of new adjoint function.
    */
   static Expr Generate(const BlockBuilder& builder, const DataflowBlock& forward_block,
                        const Array<Var>& require_grads, const Var& target_var,
-                       const Expr& orig_return_value) {
-    BackwardBindingGenerator generator(builder);
+                       const Array<Var>& orig_params, const Expr& orig_return_value,
+                       const VarIdSet& checkpoints) {
+    CheckpointGenerator checkpoint_generator(builder, orig_params, forward_block, checkpoints);
+    BackwardBindingGenerator generator(builder, checkpoint_generator);
 
-    // Initialize the adjoint of target_var as ones op. We have already check the target.
+    // Initialize the adjoint of target_var as ones op. We have already checked the target.
     auto* target_sinfo = GetStructInfoAs<TensorStructInfoNode>(target_var);
-    const Expr& target_adjoint = ones(target_sinfo->shape.value(), target_sinfo->dtype);
-    UpdateStructInfo(target_adjoint, GetRef<StructInfo>(target_sinfo));
-    generator.adjoint_msg_map_.Set(target_var, AdjointMsg(target_adjoint));
+    generator.UpdateAdjoint(target_var, ones(target_sinfo->shape.value(), target_sinfo->dtype));
 
-    // We do reverse-mode ad, so visit bindings backwards
+    // Do reverse-mode ad, so visit bindings backwards
     for (auto it = forward_block->bindings.rbegin(); it != forward_block->bindings.rend(); ++it) {
       generator.VisitBinding(*it);
     }
@@ -77,29 +306,26 @@ class BackwardBindingGenerator : private ExprVisitor {
   }
 
  private:
-  explicit BackwardBindingGenerator(const BlockBuilder& builder) : builder_(builder) {}
+  explicit BackwardBindingGenerator(const BlockBuilder& builder,
+                                    const CheckpointGenerator& checkpoint_generator)
+      : builder_(builder), checkpoint_generator_(checkpoint_generator) {}
 
   void VisitBinding(const Binding& binding) final {
     // TODO(chaofan, yixin): support other types of bindings
-    CHECK(binding->IsInstance<VarBindingNode>()) << "now only support VarBindingNode";
+    CHECK(binding->IsInstance<VarBindingNode>()) << "Now only support VarBindingNode";
     auto* var_binding = binding.as<VarBindingNode>();
 
-    auto it = adjoint_msg_map_.find(var_binding->var);
-    if (it == adjoint_msg_map_.end()) {
-      // This var is not used in the following bindings
+    if (adjoint_var_map_.count(var_binding->var) == 0) {
+      // Optimization: this var is not used in the following bindings
       return;
     }
 
-    // Meet the definition of binding->var
-    // Create the adjoint var and bind the adjoint value to it
-    EmitAdjoint(var_binding->var, (*it).second, true);
-
     Expr value = var_binding->value;
     // TODO(chaofan, yixin): support other types of binding values
     CHECK(value->IsInstance<CallNode>() || value->IsInstance<TupleNode>() ||
           value->IsInstance<TupleGetItemNode>() || value->IsInstance<VarNode>() ||
           value->IsInstance<ConstantNode>())
-        << "now does not support the type of binding value: " << value;
+        << "Now does not support the type of binding value: " << value;
 
     ExprVisitor::VisitBinding_(var_binding);
   }
@@ -114,44 +340,66 @@ class BackwardBindingGenerator : private ExprVisitor {
 
     static const OpAttrMap<FPrimalGradient>& gradient_op_map =
         Op::GetAttrMap<FPrimalGradient>("FPrimalGradient");
+    static const constexpr char* te_grad_func_prefix = "tvm.relax.te_grad._register.";
 
     Var adjoint_var = adjoint_var_map_[binding->var];
     const Op& call_op = Downcast<Op>(call->op);
 
+    // Support for checkpointing
+    auto [checkpoint_var, checkpoint_call] =
+        checkpoint_generator_.UpdateBinding(binding->var, GetRef<Call>(call));
+
     if (call_op == Op::Get("relax.call_tir")) {
-      LOG(FATAL) << "Differentiation of call_tir op is not supported yet.";
+      LOG(FATAL) << "Differentiation of call_tir op without registering corresponding gradient "
+                    "function is not supported yet.";
+    } else if (call_op == Op::Get("relax.call_tir_with_grad")) {
+      // tir gradient registering
+      auto te_grad_name = call->attrs.as<CallTIRWithGradAttrs>()->te_grad_name;
+      auto* grad_func = tvm::runtime::Registry::Get(te_grad_func_prefix + te_grad_name);
+      CHECK(grad_func) << "TIR gradient function " << te_grad_name << " is not registered";
+      Var partials =
+          (*grad_func)(checkpoint_var, Downcast<Call>(checkpoint_call), adjoint_var, builder_);
+      Tuple args = Downcast<Tuple>(call->args[1]);
+      auto* tuple_sinfo = GetStructInfoAs<TupleStructInfoNode>(partials);
+      if (!tuple_sinfo) {
+        // result_var is a tensor
+        ICHECK(args->fields.size() == 1);
+        UpdateAdjoint(args->fields[0], partials);
+      } else {
+        ICHECK(args->fields.size() == tuple_sinfo->fields.size());
+        for (int i = 0; i < static_cast<int>(args->fields.size()); ++i) {
+          UpdateAdjoint(args->fields[i], TupleGetItem(partials, i));
+        }
+      }
     } else {
-      Array<Expr> partials =
-          gradient_op_map[call_op](binding->var, GetRef<Call>(call), adjoint_var, builder_);
+      const Array<Expr>& partials = gradient_op_map[call_op](
+          checkpoint_var, Downcast<Call>(checkpoint_call), adjoint_var, builder_);
       ICHECK(partials.size() == call->args.size()) << "partials number != inputs number";
       for (size_t i = 0; i < partials.size(); ++i) {
-        if (IsCallNoGrad(partials[i])) {  // no grad: don't update
+        Expr partial = partials[i];
+        if (IsCallNoGrad(partial)) {  // no grad: don't update
           continue;
         }
-        if (!partials[i]->struct_info_.defined()) {
-          UpdateStructInfo(partials[i], GetStructInfo(call->args[i]));
-        }
-        UpdateAdjoint(call->args[i], partials[i]);
+        UpdateAdjoint(call->args[i], partial);
       }
     }
   }
 
   // For Tuple nodes, we would iterate over the input tuple and update adjoint exprs for each input
   // e.g.
-  // a = (b, c)
+  // a = (b, c) -->
   // b_adjoint += a_adjoint_var[0], c_adjoint += a_adjoint_var[1]
-  // a = ((b, c), d)
+  //
+  // a = ((b, c), d) -->
   // b_adjoint += a_adjoint_var[0][0], c_adjoint += a_adjoint_var[0][1],
   // d_adjoint += a_adjoint_var[1]
-  //
-  // Here we use adjoint_var to simplify calculation
   void VisitBinding_(const VarBindingNode* binding, const TupleNode* tuple) final {
     UpdateAdjoint(GetRef<Tuple>(tuple), adjoint_var_map_[binding->var]);
   }
 
   // For TupleGetItem nodes, we do a partial update
   // e.g.
-  // b = a[0]
+  // b = a[0] -->
   // a_adjoint[0] += b_adjoint_var
   // If a_adjoint does not exist, we would create a zeros tuple as a_adjoint first, and then add
   void VisitBinding_(const VarBindingNode* binding, const TupleGetItemNode* tuple_get_item) final {
@@ -161,14 +409,16 @@ class BackwardBindingGenerator : private ExprVisitor {
     ICHECK(tuple_sinfo) << "The tuple field of a TupleGetItem must has a TupleStructInfo";
 
     const Var& tuple_var = Downcast<Var>(tuple_get_item->tuple);
-    if (adjoint_msg_map_.count(tuple_var) == 0) {
-      const AdjointMsg& init = InitZerosAdjointNested(GetRef<StructInfo>(tuple_sinfo));
-      adjoint_msg_map_.Set(tuple_var, init);
+    if (adjoint_var_map_.count(tuple_var) == 0) {
+      auto nested_zeros = Downcast<Tuple>(NestedZeros(GetRef<StructInfo>(tuple_sinfo)));
+      auto tuple_fields = nested_zeros->fields;
+      tuple_fields.Set(tuple_get_item->index, adjoint_var_map_[binding->var]);
+      EmitAdjoint(tuple_var, Tuple(tuple_fields), false);
+    } else {
+      Expr updated_adjoint = AddInTuple(adjoint_var_map_[tuple_var], tuple_get_item->index,
+                                        adjoint_var_map_[binding->var]);
+      EmitAdjoint(tuple_var, updated_adjoint, false);
     }
-
-    adjoint_msg_map_.Set(tuple_var,
-                         AddInAdjointMsg(adjoint_msg_map_[tuple_var], tuple_get_item->index,
-                                         ExprToAdjointMsg(adjoint_var_map_[binding->var])));
   }
 
   // For assign nodes, we add the adjoint of output to the adjoint of input
@@ -183,16 +433,24 @@ class BackwardBindingGenerator : private ExprVisitor {
   // For constant nodes, we do not have to handle it because it does not contribute to the adjoint
   void VisitBinding_(const VarBindingNode* binding, const ConstantNode* var) final { return; }
 
-  // Add partial (Expr type) to the adjoint of expr
+  // Add partial to the adjoint of expr
+  // expr may be a argument of a func call / tuple definition. Its type can be
+  // 1) var 2) constant (in this case, the adjoint will not be updated)
+  // 3) (maybe nested) tuple of vars / constant
+  //
+  // We use NestedMsg to simplify handling (nested) tuples. That requires converting partial from
+  // expr to NestedMsg or backwards.
   void UpdateAdjoint(const Expr& expr, const Expr& partial) {
-    DecomposeNestedMsg(expr, ExprToAdjointMsg(partial), [&](Expr leaf, AdjointMsg msg) {
+    AdjointMsg partial_msg = ExprToAdjointMsg(builder_->Normalize(partial));
+    DecomposeNestedMsg(expr, partial_msg, [&](Expr leaf, AdjointMsg msg) {
       if (leaf->IsInstance<VarNode>()) {
         const Var& v = Downcast<Var>(leaf);
-        if (adjoint_msg_map_.count(v) == 0) {
-          adjoint_msg_map_.Set(v, msg);
-        } else {
-          adjoint_msg_map_.Set(v, TupleAwareAdd(adjoint_msg_map_[v], msg));
+        Expr updated_adjoint_expr = builder_->Normalize(AdjointMsgToExpr(msg));
+        auto it = adjoint_var_map_.find(v);
+        if (it != adjoint_var_map_.end()) {
+          updated_adjoint_expr = TupleAwareAdd((*it).second, updated_adjoint_expr);
         }
+        EmitAdjoint(v, updated_adjoint_expr, false);
       } else if (leaf->IsInstance<ConstantNode>()) {
         // nothing to do
       } else if (leaf->IsInstance<ShapeExprNode>()) {
@@ -205,21 +463,6 @@ class BackwardBindingGenerator : private ExprVisitor {
     });
   }
 
-  // Transform the adjoint expressed as NestedMsg<Expr> into adjoint Expr, and then emit it
-  // If the adjoint is assigned to a DataflowVar (the adjoint corresponds to a non-output binding),
-  // it would be stored in adjoint_var_map_ for future lookup
-  Var EmitAdjoint(const Var& source_var, const AdjointMsg& adjoint, bool is_dataflow_var) {
-    Var adjoint_var;
-    if (is_dataflow_var) {
-      adjoint_var = builder_->Emit(AdjointMsgToExpr(adjoint), source_var->name_hint() + "_adjoint");
-      adjoint_var_map_.Set(source_var, adjoint_var);
-    } else {
-      adjoint_var =
-          builder_->EmitOutput(AdjointMsgToExpr(adjoint), source_var->name_hint() + "_adjoint");
-    }
-    return adjoint_var;
-  }
-
   // Handle the return value of the AD function.
   // Returns the new return value, which would be like:
   // Tuple(original_return_value,
@@ -229,19 +472,29 @@ class BackwardBindingGenerator : private ExprVisitor {
     Array<Expr> out_adjoints;
 
     for (Var var : require_grads) {
-      // If the var don't have adjoint msg, it do not contribute to the target
-      // so its adjoint is zeros
-      AdjointMsg adjoint =
-          adjoint_msg_map_.Get(var).value_or(InitZerosAdjointNested(GetStructInfo(var)));
-      Var adjoint_var = EmitAdjoint(var, adjoint, false);
-      out_adjoints.push_back(adjoint_var);
+      // If the var don't have adjoint var, it do not contribute to the target. So its adjoint is
+      // zeros
+      auto it = adjoint_var_map_.find(var);
+      if (it == adjoint_var_map_.end()) {
+        UpdateAdjoint(var, NestedZeros(GetStructInfo(var)));
+      }
+      Var adjoint_output_var = EmitAdjoint(var, adjoint_var_map_[var], true);
+      out_adjoints.push_back(adjoint_output_var);
     }
 
     return Tuple({orig_return_value, Tuple(out_adjoints)});
   }
 
-  static bool IsCallZeros(const Expr& expr) {
-    return expr->IsInstance<CallNode>() && Downcast<Call>(expr)->op == Op::Get("relax.zeros");
+  // Emit the adjoint expr as the name `original_var_name` + "_adjoint"
+  Var EmitAdjoint(const Var& source_var, const Expr& adjoint, bool is_output) {
+    Var adjoint_var;
+    if (is_output) {
+      adjoint_var = builder_->EmitOutput(adjoint, source_var->name_hint() + "_adjoint_out");
+    } else {
+      adjoint_var = builder_->Emit(adjoint, source_var->name_hint() + "_adjoint");
+      adjoint_var_map_.Set(source_var, adjoint_var);
+    }
+    return adjoint_var;
   }
 
   static bool IsCallNoGrad(const Expr& expr) {
@@ -266,99 +519,139 @@ class BackwardBindingGenerator : private ExprVisitor {
     });
   }
 
-  // Create a zeros AdjointMsg with specified struct info
-  // When sinfo is TupleStructInfo, we would create a nested zeros Tuple
-  static AdjointMsg InitZerosAdjointNested(const StructInfo& sinfo) {
-    return MapToNestedMsg<Expr>(sinfo, [](StructInfo sinfo) {
+  // Create a zeros Expr with specified struct info
+  // When sinfo is TupleStructInfo, we would create a (nested) Tuple containing zeros
+  static Expr NestedZeros(const StructInfo& sinfo) {
+    AdjointMsg msg = MapToNestedMsg<Expr>(sinfo, [](StructInfo sinfo) {
       auto* tensor_sinfo = sinfo.as<TensorStructInfoNode>();
       ICHECK(tensor_sinfo) << "The leaf of adjoint should be a Tensor.";
-      ICHECK(tensor_sinfo->shape.defined()) << "Error: missing shape when building zeros tuple.";
+      ICHECK(tensor_sinfo->shape.defined()) << "Missing shape when building zeros tuple.";
       const Expr& init = zeros(tensor_sinfo->shape.value(), tensor_sinfo->dtype);
-      UpdateStructInfo(init, sinfo);
       return init;
     });
+    return AdjointMsgToExpr(msg);
   }
 
-  // Return base + increment. A tuple-aware addition.
-  static AdjointMsg TupleAwareAdd(const AdjointMsg& base, const AdjointMsg& increment) {
-    return CombineNestedMsg(base, increment, [&](Expr lhs, Expr rhs) {
-      // a small optimization: a+0=a, 0+a=a.
-      if (IsCallZeros(lhs)) {
-        return rhs;
-      } else if (IsCallZeros(rhs)) {
-        return lhs;
-      }
-      auto* sinfo = GetStructInfoAs<TensorStructInfoNode>(lhs);
-      ICHECK(sinfo) << "The leaf of adjoint should have StructInfo and be a Tensor.";
-      ICHECK(GetStructInfoAs<TensorStructInfoNode>(rhs))
-          << "The leaf of adjoint should have StructInfo and be a Tensor.";
-      Expr res = add(lhs, rhs);
-      UpdateStructInfo(res, GetRef<StructInfo>(sinfo));
-      return res;
-    });
+  // Return lhs + rhs. Requires lhs and rhs has the same StructInfo.
+  // Use NestedMsg to handle cases when lhs and rhs are tuples.
+  static Expr TupleAwareAdd(const Expr& lhs, const Expr& rhs) {
+    AdjointMsg res = CombineNestedMsg(
+        ExprToAdjointMsg(lhs), ExprToAdjointMsg(rhs), [](Expr l_leaf, Expr r_leaf) {
+          auto* sinfo = GetStructInfoAs<TensorStructInfoNode>(l_leaf);
+          ICHECK(sinfo) << "The leaf of adjoint should have StructInfo and be a Tensor.";
+          ICHECK(GetStructInfoAs<TensorStructInfoNode>(r_leaf))
+              << "The leaf of adjoint should have StructInfo and be a Tensor.";
+          Expr res = add(l_leaf, r_leaf);
+          UpdateStructInfo(res, GetRef<StructInfo>(sinfo));
+          return res;
+        });
+    return AdjointMsgToExpr(res);
   }
 
   // Perform an addition in a specified position of tuple.
-  // e.g. tuple=(a, b, c), index=1, increment=d, then return (a, b+d, c)
-  static AdjointMsg AddInAdjointMsg(const AdjointMsg& adjoint, int index,
-                                    const AdjointMsg& increment) {
-    ICHECK(adjoint.IsNested()) << "The adjoint should be nested.";
-    Array<AdjointMsg> arr = adjoint.NestedArray();
-    ICHECK(index >= 0 && index < static_cast<int>(arr.size()));
-    arr.Set(index, TupleAwareAdd(arr[index], increment));
-    return AdjointMsg(arr);
+  // tuple[index] += increment
+  // Impl:
+  // Step 1) t1 = tuple[0], t2 = tuple[1], t3 = tuple[2]
+  // Step 2)t2_new = t2 + increment (TupleAwareAdd)
+  // Step 3) tuple_new = (t1, t2_new, t3)
+  static Expr AddInTuple(const Expr& tuple, int index, const Expr& increment) {
+    auto* sinfo = GetStructInfoAs<TupleStructInfoNode>(tuple);
+    ICHECK(sinfo) << "The first argument of AddInTuple should have tuple struct info.";
+    ICHECK(index >= 0 && index < static_cast<int>(sinfo->fields.size()));
+    Array<Expr> res;
+    for (size_t i = 0; i < sinfo->fields.size(); ++i) {
+      Expr field;
+      if (const auto* expr_tuple = tuple.as<TupleNode>()) {
+        field = expr_tuple->fields[i];
+      } else {
+        field = TupleGetItem(tuple, i);
+      }
+      if (static_cast<int>(i) == index) {
+        field = TupleAwareAdd(field, increment);
+      }
+      res.push_back(field);
+    }
+    return Tuple(res);
   }
 
   // The block builder of the corresponding GradientMutator, to emit bindings
   BlockBuilder builder_;
   // Forward Var to its adjoint Var
   Map<Var, Var> adjoint_var_map_;
-  // Forward Var to its adjoint NestedMsg<Expr>
-  // We use NestedMsg<Expr> to save the adjoint information (equivalent to adjoint Expr)
-  // When emitting, adjoint information will be transformed into adjoint Expr
-  Map<Var, AdjointMsg> adjoint_msg_map_;
+  // The generator for checkpoint bindings
+  CheckpointGenerator checkpoint_generator_;
 };
 
 class GradientMutator : private ExprMutator {
  public:
   static IRModule Transform(IRModule mod, String func_name, Optional<Array<Var>> require_grads,
                             int target_index) {
-    auto* old_func_ptr = mod->Lookup(func_name).as<FunctionNode>();
-    CHECK(old_func_ptr) << func_name << "is not a Relax Function";
-    auto old_func = GetRef<Function>(old_func_ptr);
-
-    // when require_grads is not specified, it would be set to all params of the function
-    auto require_grads_value = require_grads.value_or(old_func->params);
-
-    CheckRequireGrads(require_grads_value, old_func->params, func_name);
-
-    Function new_func = CopyWithNewVars(old_func);
-    // map the parameter list into new params
-    for (size_t i = 0; i < require_grads_value.size(); ++i) {
-      int idx =
-          std::find(old_func->params.begin(), old_func->params.end(), require_grads_value[i]) -
-          old_func->params.begin();
-      require_grads_value.Set(i, new_func->params[idx]);
+    // Step 1. Copy function
+    auto* old_func = mod->Lookup(func_name).as<FunctionNode>();
+    CHECK(old_func) << func_name << "is not a Relax Function";
+    auto new_func = CopyWithNewVars(GetRef<Function>(old_func));
+
+    // Step 2. Handle the checkpoints and eliminate start_checkpoint and end_checkpoint ops
+    auto checkpoint_collected = CheckpointCollector::Collect(new_func);
+    new_func = checkpoint_collected.first;
+    auto checkpoints = checkpoint_collected.second;
+
+    // Step 3. Collect call_tir_with_grad information
+    auto tir_grad_collected = CallTIRWithGradEliminator::Transform(new_func);
+
+    // Step 4. Handle require_grads
+    // When require_grads is not specified, it would be set to all params of the function
+    if (require_grads) {
+      CheckRequireGrads(require_grads.value(), old_func->params, func_name);
     }
+    // then map the parameter list into new params
+    auto require_grads_value = require_grads.value_or(old_func->params).Map([&](const Var& v) {
+      return new_func->params[std::find(old_func->params.begin(), old_func->params.end(), v) -
+                              old_func->params.begin()];
+    });
 
-    GradientMutator mutator(mod, require_grads_value, target_index);
-
-    // make the adjoint public
-    auto new_name = func_name + "_adjoint";
-    Function new_func_transformed = WithAttr(Downcast<Function>(mutator.VisitExpr(new_func)),
-                                             tvm::attr::kGlobalSymbol, new_name);
-
-    mutator.builder_->AddFunction(new_func_transformed, new_name);
-    return mutator.builder_->GetContextIRModule();
+    // Step 5. Generate the adjoint function, use RemoveAllUnused to simplify it, and then return
+    // the IRModule with the adjoint function
+    return GradientMutator(mod, require_grads_value, target_index, checkpoints)
+        .AddAdjointFunction(new_func, func_name, true);
   }
 
  private:
-  GradientMutator(const IRModule& module, const Array<Var>& require_grads, int target_index)
-      : ExprMutator(module), require_grads_(require_grads), target_index_(target_index) {}
+  GradientMutator(const IRModule& module, const Array<Var>& require_grads, int target_index,
+                  const VarIdSet& checkpoints)
+      : ExprMutator(module),
+        require_grads_(require_grads),
+        checkpoints_(checkpoints),
+        target_index_(target_index) {}
+
+  // Add the adjoint function of func to the IRModule using BlockBuilder
+  IRModule AddAdjointFunction(const Function& func, const String& func_name,
+                              bool remove_all_unused = true) {
+    // Step 5.1 forward -> forward + backward
+    auto new_func = Downcast<Function>(VisitExpr(func));
+
+    // Step 5.2 Convert call_tir_with_grad nodes into call_tir nodes
+    // because call_tir_with_grad nodes is not actually implemented
+    new_func = CallTIRWithGradEliminator::Transform(new_func);
+
+    if (remove_all_unused) {
+      new_func = RemoveAllUnused(new_func);
+    }
+
+    // Step 5.3 mark the transformed function as public
+    // because the original function may be public, and have gsymbol attribute as func_name
+    auto new_func_name = func_name + "_adjoint";
+    auto new_func_with_gsymbol = WithAttr(new_func, tvm::attr::kGlobalSymbol, new_func_name);
+
+    // Step 5.4 Add the transformed function to IRModule
+    builder_->AddFunction(new_func_with_gsymbol, new_func_name);
+    return builder_->GetContextIRModule();
+  }
 
   Expr VisitExpr_(const FunctionNode* func) final {
     CHECK(func->body->IsInstance<SeqExprNode>()) << "The body of the function must be SeqExpr.";
 
+    orig_params_ = func->params;
     Expr new_body = this->VisitExpr(func->body);
 
     return Function(func->params, new_body, NullOpt, func->is_pure, func->attrs);
@@ -376,7 +669,7 @@ class GradientMutator : private ExprMutator {
     CheckAndSetTarget(seq_expr->body, target_index_);
 
     BindingBlock new_block = this->VisitBindingBlock(seq_expr->blocks[0]);
-    return SeqExpr({new_block}, this->return_expr_);
+    return SeqExpr({new_block}, return_expr_);
   }
 
   BindingBlock VisitBindingBlock_(const DataflowBlockNode* block) final {
@@ -387,9 +680,9 @@ class GradientMutator : private ExprMutator {
     }
 
     // generate backward bindings and the return value
-    return_expr_ = BackwardBindingGenerator::Generate(this->builder_, GetRef<DataflowBlock>(block),
-                                                      this->require_grads_, this->target_var_,
-                                                      orig_return_expr_);
+    return_expr_ = BackwardBindingGenerator::Generate(builder_, GetRef<DataflowBlock>(block),
+                                                      require_grads_, target_var_, orig_params_,
+                                                      orig_return_expr_, checkpoints_);
 
     return builder_->EndBlock();
   }
@@ -435,13 +728,14 @@ class GradientMutator : private ExprMutator {
   // 3. the type of the input var should be Tensor of floating point dtype, or Tuple of that
   static void CheckRequireGrads(const Array<Var>& require_grads, const Array<Var>& func_params,
                                 const String& func_name) {
-    std::unordered_set<Var, ObjectPtrHash, ObjectPtrEqual> var_set;
+    VarIdSet var_set;
     for (const auto& var : require_grads) {
       CHECK(std::find(func_params.begin(), func_params.end(), var) != func_params.end())
           << "There is no Var named " << var->name_hint() << " in the parameters of the function "
           << func_name;
-      CHECK_EQ(var_set.count(var), 0) << "Var " << var->name_hint() << " appears more than once";
-      var_set.emplace(var);
+      CHECK_EQ(var_set.count(var->vid), 0)
+          << "Var " << var->name_hint() << " appears more than once";
+      var_set.emplace(var->vid);
 
       CHECK(IsNestedTensorConditioned(GetStructInfo(var), IsFloatTensorSInfo))
           << "Only Tensors of floating point dtype or Tuples of float "
@@ -452,10 +746,13 @@ class GradientMutator : private ExprMutator {
 
   // differentiation sources
   Array<Var> require_grads_;
+  // checkpoint
+  VarIdSet checkpoints_;
   // the differentiation target
   int target_index_;
   Var target_var_;
   // the return value of the original function and the differentiated function
+  Array<Var> orig_params_;
   Expr orig_return_expr_;
   Expr return_expr_;
 };
diff --git a/tests/python/relax/test_op_grad.py b/tests/python/relax/test_op_grad.py
index 01c4226d96..d27bae78e2 100644
--- a/tests/python/relax/test_op_grad.py
+++ b/tests/python/relax/test_op_grad.py
@@ -14,7 +14,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import pytest
 import tvm
+from tvm._ffi.base import TVMError
 import tvm.testing
 from tvm import relax
 from tvm.ir import Op
@@ -43,6 +45,11 @@ def test_op_correctness():
         "relax.grad.take_backward"
     )
     assert relax.op.grad.no_grad(x).op == Op.get("relax.grad.no_grad")
+    assert relax.op.grad.no_grad(x).args[0] == x
+    assert relax.op.grad.start_checkpoint(x).op == Op.get("relax.grad.start_checkpoint")
+    assert relax.op.grad.start_checkpoint(x).args[0] == x
+    assert relax.op.grad.end_checkpoint(x).op == Op.get("relax.grad.end_checkpoint")
+    assert relax.op.grad.end_checkpoint(x).args[0] == x
 
 
 def _check_inference(bb: relax.BlockBuilder, call: relax.Call, expected_sinfo: relax.StructInfo):
@@ -50,6 +57,44 @@ def _check_inference(bb: relax.BlockBuilder, call: relax.Call, expected_sinfo: r
     tvm.ir.assert_structural_equal(ret.struct_info, expected_sinfo)
 
 
+def test_start_checkpoint_input_not_var():
+    bb = relax.BlockBuilder()
+    x = relax.Var("x", R.Tensor((3, 4), "float32"))
+    y = relax.Var("y", R.Tensor((3, 4), "float32"))
+
+    # ok because x + y will be normalized into a relax Var
+    with bb.function("main", [x, y]):
+        gv = bb.emit(relax.op.grad.start_checkpoint(x + y))
+        bb.emit_func_output(gv)
+
+    # wrong: tuple will not be normalized
+    with pytest.raises(TVMError):
+        bb.normalize(relax.op.grad.start_checkpoint((x, y)))
+
+    # wrong: const will not be normalized
+    with pytest.raises(TVMError):
+        bb.normalize(relax.op.grad.start_checkpoint(relax.const(1, "float32")))
+
+
+def test_end_checkpoint_input_not_var():
+    bb = relax.BlockBuilder()
+    x = relax.Var("x", R.Tensor((3, 4), "float32"))
+    y = relax.Var("y", R.Tensor((3, 4), "float32"))
+
+    # ok because x + y will be normalized into a relax Var
+    with bb.function("main", [x, y]):
+        gv = bb.emit(relax.op.grad.end_checkpoint(x + y))
+        bb.emit_func_output(gv)
+
+    # wrong: tuple will not be normalized
+    with pytest.raises(TVMError):
+        bb.normalize(relax.op.grad.end_checkpoint((x, y)))
+
+    # wrong: const will not be normalized
+    with pytest.raises(TVMError):
+        bb.normalize(relax.op.grad.end_checkpoint(relax.const(1, "float32")))
+
+
 def test_nll_loss_backward_infer_struct_info():
     bb = relax.BlockBuilder()
 
diff --git a/tests/python/relax/test_op_gradient_numeric.py b/tests/python/relax/test_op_gradient_numeric.py
index 0a7fa3672e..4b4c5cabc4 100644
--- a/tests/python/relax/test_op_gradient_numeric.py
+++ b/tests/python/relax/test_op_gradient_numeric.py
@@ -747,7 +747,7 @@ def test_nll_loss_no_batch(target, dev, nll_reduction1, nll_weighted1, nll_ignor
     )
 
 
-(c2d_shape1, c2d_shape2, c2d_kwargs,) = tvm.testing.parameters(
+(c2d_shape1, c2d_shape2, c2d_kwargs) = tvm.testing.parameters(
     (
         (3, 2, 10, 10),
         (3, 2, 3, 3),
@@ -797,7 +797,7 @@ def test_conv2d(target, dev, c2d_shape1, c2d_shape2, c2d_kwargs):
     )
 
 
-(pool_size, pool_kwargs,) = tvm.testing.parameters(
+(pool_size, pool_kwargs) = tvm.testing.parameters(
     (
         (3, 3),
         {},
diff --git a/tests/python/relax/test_training_setup_trainer.py b/tests/python/relax/test_training_setup_trainer.py
index 401ffdf76c..0197aa9745 100644
--- a/tests/python/relax/test_training_setup_trainer.py
+++ b/tests/python/relax/test_training_setup_trainer.py
@@ -68,13 +68,14 @@ def test_simple():
                 gv: R.Tensor((), dtype="float64") = R.sum(lv1, axis=None, keepdims=False)
                 gv_adjoint: R.Tensor((), dtype="float64") = R.ones(R.shape([]), dtype="float64")
                 lv1_adjoint: R.Tensor((2, 2), dtype="float64") = R.broadcast_to(gv_adjoint, R.shape([2, 2]))
+                lv_adjoint: R.Tensor((2, 2), dtype="float64") = R.multiply(lv1_adjoint, lv)
                 lv_1: R.Tensor((2, 2), dtype="float64") = R.multiply(lv1_adjoint, lv)
-                lv1_1: R.Tensor((2, 2), dtype="float64") = R.multiply(lv1_adjoint, lv)
-                lv_adjoint: R.Tensor((2, 2), dtype="float64") = R.add(lv_1, lv1_1)
-                x1_adjoint: R.Tensor((2, 2), dtype="float64") = lv_adjoint
+                lv_adjoint1: R.Tensor((2, 2), dtype="float64") = R.add(lv_adjoint, lv_1)
+                x1_adjoint: R.Tensor((2, 2), dtype="float64") = lv_adjoint1
                 y_adjoint: R.Tensor((2, 2), dtype="float64") = x1_adjoint
-                R.output(gv, y_adjoint)
-            return (gv, (y_adjoint,))
+                y_adjoint_out: R.Tensor((2, 2), dtype="float64") = y_adjoint
+                R.output(gv, y_adjoint_out)
+            return (gv, (y_adjoint_out,))
 
         @R.function
         def optimizer(params: R.Tuple(R.Tensor((2, 2), dtype="float64")), gradients: R.Tuple(R.Tensor((2, 2), dtype="float64")), optim_states: R.Tuple(R.Tensor((), dtype="int64"))) -> R.Tuple(R.Tuple(R.Tensor((2, 2), dtype="float64")), R.Tuple(R.Tensor((), dtype="int64"))):
@@ -142,13 +143,14 @@ def test_states():
                 gv: R.Tensor((), dtype="float64") = R.sum(lv1, axis=None, keepdims=False)
                 gv_adjoint: R.Tensor((), dtype="float64") = R.ones(R.shape([]), dtype="float64")
                 lv1_adjoint: R.Tensor((2, 2), dtype="float64") = R.broadcast_to(gv_adjoint, R.shape([2, 2]))
+                lv_adjoint: R.Tensor((2, 2), dtype="float64") = R.multiply(lv1_adjoint, lv)
                 lv_1: R.Tensor((2, 2), dtype="float64") = R.multiply(lv1_adjoint, lv)
-                lv1_1: R.Tensor((2, 2), dtype="float64") = R.multiply(lv1_adjoint, lv)
-                lv_adjoint: R.Tensor((2, 2), dtype="float64") = R.add(lv_1, lv1_1)
-                x1_adjoint: R.Tensor((2, 2), dtype="float64") = lv_adjoint
+                lv_adjoint1: R.Tensor((2, 2), dtype="float64") = R.add(lv_adjoint, lv_1)
+                x1_adjoint: R.Tensor((2, 2), dtype="float64") = lv_adjoint1
                 y_adjoint: R.Tensor((2, 2), dtype="float64") = x1_adjoint
-                R.output(z1, gv, y_adjoint)
-            return ((gv, z1), (y_adjoint,))
+                y_adjoint_out: R.Tensor((2, 2), dtype="float64") = y_adjoint
+                R.output(z1, gv, y_adjoint_out)
+            return ((gv, z1), (y_adjoint_out,))
 
         @R.function
         def optimizer(params: R.Tuple(R.Tensor((2, 2), dtype="float64")), gradients: R.Tuple(R.Tensor((2, 2), dtype="float64")), optim_states: R.Tuple(R.Tensor((), dtype="int64"), R.Tensor((2, 2), dtype="float64"))) -> R.Tuple(R.Tuple(R.Tensor((2, 2), dtype="float64")), R.Tuple(R.Tensor((), dtype="int64"), R.Tensor((2, 2), dtype="float64"))):
@@ -163,9 +165,10 @@ def test_states():
                 lv1: R.Tensor((2, 2), dtype="float64") = R.multiply(R.const(0.10000000000000001, "float64"), y_v_new)
                 y_new: R.Tensor((2, 2), dtype="float64") = R.subtract(y, lv1)
                 params_new: R.Tuple(R.Tensor((2, 2), dtype="float64")) = (y_new,)
-                optim_states_new: R.Tuple(R.Tensor((), dtype="int64"), R.Tensor((2, 2), dtype="float64")) = (num_steps_new, y_v_new)
+                optim_states_new: R.Tuple(R.Tensor((), dtype="int64"), R.Tensor((2, 2), dtype="float64")) = num_steps_new, y_v_new
                 R.output(params_new, optim_states_new)
             return (params_new, optim_states_new)
+
     # fmt: on
 
     sinfo = relax.TensorStructInfo((2, 2), "float64")
diff --git a/tests/python/relax/test_transform_gradient.py b/tests/python/relax/test_transform_gradient.py
index 50063fe385..b96932f8c5 100644
--- a/tests/python/relax/test_transform_gradient.py
+++ b/tests/python/relax/test_transform_gradient.py
@@ -14,14 +14,15 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-import tvm
+import numpy as np
 import pytest
+
+import tvm
 import tvm.testing
 from tvm import relax
+from tvm._ffi.base import TVMError
 from tvm.ir.base import assert_structural_equal
 from tvm.script.parser import relax as R, tir as T, ir as I
-from tvm._ffi.base import TVMError
-import numpy as np
 
 
 def test_simple():
@@ -38,20 +39,21 @@ def test_simple():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((3, 3), "float32")) -> R.Tensor(None, "float32", ndim=0):
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"))):
             with R.dataflow():
-                gv: R.Tensor((), "float32") = R.sum(x, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                gv: R.Tensor((), dtype="float32") = R.sum(x, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint
+                R.output(gv, x_adjoint_out)
+            return (gv, (x_adjoint_out,))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor(None, "float32", ndim=0),R.Tuple(R.Tensor(None, "float32", ndim=2)),):
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                gv: R.Tensor((), "float32") = R.sum(x, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                x_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                R.output(gv, x_adjoint)
-            return (gv, (x_adjoint,))
+                gv: R.Tensor((), dtype="float32") = R.sum(x, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -74,27 +76,27 @@ def test_assign_binding():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((3, 3), "float32")) -> R.Tensor((), "float32"):
-            # block 0
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"))):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = x
-                lv2: R.Tensor((3, 3), "float32") = lv1
-                gv: R.Tensor((), "float32") = R.sum(lv2, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv1: R.Tensor((3, 3), dtype="float32") = x
+                lv2: R.Tensor((3, 3), dtype="float32") = lv1
+                gv: R.Tensor((), dtype="float32") = R.sum(lv2, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = lv2_adjoint
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = lv1_adjoint
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint
+                R.output(gv, x_adjoint_out)
+            return (gv, (x_adjoint_out,))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((3, 3), "float32"))):
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = x
-                lv2: R.Tensor((3, 3), "float32") = lv1
-                gv: R.Tensor((), "float32") = R.sum(lv2, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv2_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                lv1_adjoint: R.Tensor((3, 3), "float32") = lv2_adjoint
-                x_adjoint: R.Tensor((3, 3), "float32") = lv1_adjoint
-                R.output(gv, x_adjoint)
-            return (gv, (x_adjoint,))
+                lv1: R.Tensor((3, 3), dtype="float32") = x
+                lv2: R.Tensor((3, 3), dtype="float32") = lv1
+                gv: R.Tensor((), dtype="float32") = R.sum(lv2, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -117,27 +119,29 @@ def test_multiple_uses():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((3, 3), "float32")) -> R.Tensor((), "float32"):
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"))):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, x)
-                lv2: R.Tensor((3, 3), "float32") = R.add(lv1, x)
-                gv: R.Tensor((), "float32") = R.sum(lv2, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, x)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.add(lv1, x)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv2, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = lv2_adjoint
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = lv2_adjoint
+                x_adjoint1: R.Tensor((3, 3), dtype="float32") = R.add(x_adjoint, lv1_adjoint)
+                x_adjoint2: R.Tensor((3, 3), dtype="float32") = R.add(x_adjoint1, lv1_adjoint)
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint2
+                R.output(gv, x_adjoint_out)
+            return (gv, (x_adjoint_out,))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((3, 3), "float32"))):
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, x)
-                lv2: R.Tensor((3, 3), "float32") = R.add(lv1, x)
-                gv: R.Tensor((), "float32") = R.sum(lv2, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv2_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                lv1_adjoint: R.Tensor((3, 3), "float32") = lv2_adjoint
-                lv: R.Tensor((3, 3), "float32") = R.add(lv2_adjoint, lv1_adjoint)
-                x_adjoint: R.Tensor((3, 3), "float32") = R.add(lv, lv1_adjoint)
-                R.output(gv, x_adjoint)
-            return (gv, (x_adjoint,))
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, x)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.add(lv1, x)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv2, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -149,7 +153,7 @@ def test_unused():
     @I.ir_module
     class Before:
         @R.function
-        def main(x: R.Tensor((3, 3), "float32")):
+        def main(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32")):
             with R.dataflow():
                 lv1 = R.add(x, x)
                 lv2 = R.add(lv1, x)
@@ -160,24 +164,25 @@ def test_unused():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((3, 3), "float32")) -> R.Tensor((), "float32"):
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, x)
-                lv2: R.Tensor((3, 3), "float32") = R.add(lv1, x)
-                gv: R.Tensor((), "float32") = R.sum(x, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                gv: R.Tensor((), dtype="float32") = R.sum(x, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint
+                y_adjoint: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                y_adjoint_out: R.Tensor((3, 3), dtype="float32") = y_adjoint
+                R.output(gv, x_adjoint_out, y_adjoint_out)
+            return (gv, (x_adjoint_out, y_adjoint_out))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((3, 3), "float32"))):
+        def main(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, x)
-                lv2: R.Tensor((3, 3), "float32") = R.add(lv1, x)
-                gv: R.Tensor((), "float32") = R.sum(x, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                x_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                R.output(gv, x_adjoint)
-            return (gv, (x_adjoint,))
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, x)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.add(lv1, x)
+                gv: R.Tensor((), dtype="float32") = R.sum(x, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -189,11 +194,7 @@ def test_default_require_grads():
     @I.ir_module
     class Before:
         @R.function
-        def main(
-            x: R.Tensor((3, 3), "float32"),
-            y: R.Tensor((3, 3), "float32"),
-            z: R.Tensor((3, 3), "float32"),
-        ):
+        def main(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32"), z: R.Tensor((3, 3), "float32")):
             with R.dataflow():
                 lv1 = R.add(x, y)
                 lv2 = R.add(lv1, z)
@@ -204,33 +205,31 @@ def test_default_require_grads():
     @I.ir_module
     class Expected1:
         @R.function
-        def main(
-            x: R.Tensor((3, 3), "float32"),
-            y: R.Tensor((3, 3), "float32"),
-            z: R.Tensor((3, 3), "float32"),
-        ) -> R.Tensor((), "float32"):
-            # block 0
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, y)
-                lv2: R.Tensor((3, 3), "float32") = R.add(lv1, z)
-                gv: R.Tensor((), "float32") = R.sum(lv2, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, y)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.add(lv1, z)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv2, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = lv2_adjoint
+                z_adjoint: R.Tensor((3, 3), dtype="float32") = lv2_adjoint
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = lv1_adjoint
+                y_adjoint: R.Tensor((3, 3), dtype="float32") = lv1_adjoint
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint
+                y_adjoint_out: R.Tensor((3, 3), dtype="float32") = y_adjoint
+                z_adjoint_out: R.Tensor((3, 3), dtype="float32") = z_adjoint
+                R.output(gv, x_adjoint_out, y_adjoint_out, z_adjoint_out)
+            return (gv, (x_adjoint_out, y_adjoint_out, z_adjoint_out))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32"), z: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"))):
+        def main(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, y)
-                lv2: R.Tensor((3, 3), "float32") = R.add(lv1, z)
-                gv: R.Tensor((), "float32") = R.sum(lv2, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv2_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                lv1_adjoint: R.Tensor((3, 3), "float32") = lv2_adjoint
-                x_adjoint: R.Tensor((3, 3), "float32") = lv1_adjoint
-                y_adjoint: R.Tensor((3, 3), "float32") = lv1_adjoint
-                z_adjoint: R.Tensor((3, 3), "float32") = lv2_adjoint
-                R.output(gv, x_adjoint, y_adjoint, z_adjoint)
-            return (gv, (x_adjoint, y_adjoint, z_adjoint))
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, y)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.add(lv1, z)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv2, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After1 = relax.transform.Gradient("main")(Before)
@@ -240,28 +239,27 @@ def test_default_require_grads():
     @I.ir_module
     class Expected2:
         @R.function
-        def main(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32"), z: R.Tensor((3, 3), "float32")) -> R.Tensor((), "float32"):
-            # block 0
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"))):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, y)
-                lv2: R.Tensor((3, 3), "float32") = R.add(lv1, z)
-                gv: R.Tensor((), "float32") = R.sum(lv2, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, y)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.add(lv1, z)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv2, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = lv2_adjoint
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = lv1_adjoint
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint
+                R.output(gv, x_adjoint_out)
+            return (gv, (x_adjoint_out,))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32"), z: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((3, 3), "float32"))):
-            # block 0
+        def main(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, y)
-                lv2: R.Tensor((3, 3), "float32") = R.add(lv1, z)
-                gv: R.Tensor((), "float32") = R.sum(lv2, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv2_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                lv1_adjoint: R.Tensor((3, 3), "float32") = lv2_adjoint
-                x_adjoint: R.Tensor((3, 3), "float32") = lv1_adjoint
-                R.output(gv, x_adjoint)
-            return (gv, (x_adjoint,))
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, y)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.add(lv1, z)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv2, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After2 = relax.transform.Gradient("main", require_grads=Before["main"].params[0])(Before)
@@ -284,25 +282,27 @@ def test_target_index():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((), "float32"), R.Tensor((), "float32")):
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((), dtype="float32"), R.Tensor((), dtype="float32")), R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = x
-                lv2: R.Tensor((), "float32") = R.sum(x, axis=None, keepdims=False)
-                lv3: R.Tensor((), "float32") = R.sum(y, axis=None, keepdims=False)
-                R.output(lv1, lv2, lv3)
-            return (lv1, lv2, lv3)
+                lv1: R.Tensor((3, 3), dtype="float32") = x
+                lv2: R.Tensor((), dtype="float32") = R.sum(x, axis=None, keepdims=False)
+                lv3: R.Tensor((), dtype="float32") = R.sum(y, axis=None, keepdims=False)
+                lv3_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                y_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(lv3_adjoint, R.shape([3, 3]))
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint
+                y_adjoint_out: R.Tensor((3, 3), dtype="float32") = y_adjoint
+                R.output(lv1, lv2, lv3, x_adjoint_out, y_adjoint_out)
+            return ((lv1, lv2, lv3), (x_adjoint_out, y_adjoint_out))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((), "float32"), R.Tensor((), "float32")), R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"))):
+        def main(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((), dtype="float32"), R.Tensor((), dtype="float32")):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = x
-                lv2: R.Tensor((), "float32") = R.sum(x, axis=None, keepdims=False)
-                lv3: R.Tensor((), "float32") = R.sum(y, axis=None, keepdims=False)
-                lv3_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                x_adjoint: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                y_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(lv3_adjoint, (3, 3))
-                R.output(lv1, lv2, lv3, x_adjoint, y_adjoint)
-            return ((lv1, lv2, lv3), (x_adjoint, y_adjoint))
+                lv1: R.Tensor((3, 3), dtype="float32") = x
+                lv2: R.Tensor((), dtype="float32") = R.sum(x, axis=None, keepdims=False)
+                lv3: R.Tensor((), dtype="float32") = R.sum(y, axis=None, keepdims=False)
+                R.output(lv1, lv2, lv3)
+            return (lv1, lv2, lv3)
     # fmt: on
 
     After = relax.transform.Gradient("main", target_index=2)(Before)
@@ -328,39 +328,43 @@ def test_tuple():
                 R.output(gv)
             return gv
 
+
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")), y: R.Tensor((3, 3), "float32"), z: R.Tensor((3, 3), "float32")) -> R.Tensor(None, "float32", ndim=0):
+        def main_adjoint(x: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))):
             with R.dataflow():
-                lv1: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (y, z)
-                lv2: R.Tensor((3, 3), "float32") = x[0]
-                lv3: R.Tensor((3, 3), "float32") = lv1[0]
-                lv4: R.Tensor((3, 3), "float32") = R.add(lv2, lv3)
-                gv: R.Tensor((), "float32") = R.sum(lv4, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv1: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (y, z)
+                lv2: R.Tensor((3, 3), dtype="float32") = x[0]
+                lv3: R.Tensor((3, 3), dtype="float32") = lv1[0]
+                lv4: R.Tensor((3, 3), dtype="float32") = R.add(lv2, lv3)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv4, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv4_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = lv4_adjoint
+                lv3_adjoint: R.Tensor((3, 3), dtype="float32") = lv4_adjoint
+                lv: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv1_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (lv3_adjoint, lv)
+                lv1_1: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                x_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (lv2_adjoint, lv1_1)
+                y_adjoint: R.Tensor((3, 3), dtype="float32") = lv1_adjoint[0]
+                z_adjoint: R.Tensor((3, 3), dtype="float32") = lv1_adjoint[1]
+                x_adjoint_out: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = x_adjoint
+                y_adjoint_out: R.Tensor((3, 3), dtype="float32") = y_adjoint
+                z_adjoint_out: R.Tensor((3, 3), dtype="float32") = z_adjoint
+                R.output(gv, x_adjoint_out, y_adjoint_out, z_adjoint_out)
+            return (gv, (x_adjoint_out, y_adjoint_out, z_adjoint_out))
 
         @R.function
-        def main_adjoint(x: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")), y: R.Tensor((3, 3), "float32"), z: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")), R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"))):
+        def main(x: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (y, z)
-                lv2: R.Tensor((3, 3), "float32") = x[0]
-                lv3: R.Tensor((3, 3), "float32") = lv1[0]
-                lv4: R.Tensor((3, 3), "float32") = R.add(lv2, lv3)
-                gv: R.Tensor((), "float32") = R.sum(lv4, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv4_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                lv3_adjoint: R.Tensor((3, 3), "float32") = lv4_adjoint
-                lv2_adjoint: R.Tensor((3, 3), "float32") = lv4_adjoint
-                lv: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                lv1_adjoint: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (lv3_adjoint, lv)
-                lv11: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                x_adjoint: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (lv2_adjoint, lv11)
-                y_adjoint: R.Tensor((3, 3), "float32") = lv1_adjoint[0]
-                z_adjoint: R.Tensor((3, 3), "float32") = lv1_adjoint[1]
-                R.output(gv, x_adjoint, y_adjoint, z_adjoint)
-            return (gv, (x_adjoint, y_adjoint, z_adjoint))
+                lv1: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (y, z)
+                lv2: R.Tensor((3, 3), dtype="float32") = x[0]
+                lv3: R.Tensor((3, 3), dtype="float32") = lv1[0]
+                lv4: R.Tensor((3, 3), dtype="float32") = R.add(lv2, lv3)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv4, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -375,57 +379,60 @@ def test_tuple_assignment():
         def main(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32")):
             with R.dataflow():
                 lv1 = (x, y)
-                lv4 = lv1[0]
-                lv7 = R.add(lv4, x)
-                lv2 = lv1
-                lv3 = lv2[0]
-                lv5 = R.add(lv3, lv7)
-                gv = R.sum(lv5)
+                lv2 = lv1[0]
+                lv3 = R.add(lv2, x)
+                lv4 = lv1
+                lv5 = lv4[0]
+                lv6 = R.add(lv5, lv3)
+                gv = R.sum(lv6)
                 R.output(gv)
             return gv
 
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32")) -> R.Tensor((), "float32"):
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))):
             with R.dataflow():
-                lv1: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (x, y)
-                lv4: R.Tensor((3, 3), "float32") = lv1[0]
-                lv7: R.Tensor((3, 3), "float32") = R.add(lv4, x)
-                lv2: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = lv1
-                lv3: R.Tensor((3, 3), "float32") = lv2[0]
-                lv5: R.Tensor((3, 3), "float32") = R.add(lv3, lv7)
-                gv: R.Tensor((), "float32") = R.sum(lv5, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv1: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (x, y)
+                lv2: R.Tensor((3, 3), dtype="float32") = lv1[0]
+                lv3: R.Tensor((3, 3), dtype="float32") = R.add(lv2, x)
+                lv4: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv1
+                lv5: R.Tensor((3, 3), dtype="float32") = lv4[0]
+                lv6: R.Tensor((3, 3), dtype="float32") = R.add(lv5, lv3)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv6, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv6_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv5_adjoint: R.Tensor((3, 3), dtype="float32") = lv6_adjoint
+                lv3_adjoint: R.Tensor((3, 3), dtype="float32") = lv6_adjoint
+                lv: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv4_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (lv5_adjoint, lv)
+                lv1_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv4_adjoint
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = lv3_adjoint
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = lv3_adjoint
+                lv1_1: R.Tensor((3, 3), dtype="float32") = lv1_adjoint[0]
+                lv2_1: R.Tensor((3, 3), dtype="float32") = R.add(lv1_1, lv2_adjoint)
+                lv3_1: R.Tensor((3, 3), dtype="float32") = lv1_adjoint[1]
+                lv1_adjoint1: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (lv2_1, lv3_1)
+                lv4_1: R.Tensor((3, 3), dtype="float32") = lv1_adjoint1[0]
+                x_adjoint1: R.Tensor((3, 3), dtype="float32") = R.add(x_adjoint, lv4_1)
+                y_adjoint: R.Tensor((3, 3), dtype="float32") = lv1_adjoint1[1]
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint1
+                y_adjoint_out: R.Tensor((3, 3), dtype="float32") = y_adjoint
+                R.output(gv, x_adjoint_out, y_adjoint_out)
+            return (gv, (x_adjoint_out, y_adjoint_out))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"))):
-            # block 0
+        def main(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (x, y)
-                lv4: R.Tensor((3, 3), "float32") = lv1[0]
-                lv7: R.Tensor((3, 3), "float32") = R.add(lv4, x)
-                lv2: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = lv1
-                lv3: R.Tensor((3, 3), "float32") = lv2[0]
-                lv5: R.Tensor((3, 3), "float32") = R.add(lv3, lv7)
-                gv: R.Tensor((), "float32") = R.sum(lv5, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv5_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                lv3_adjoint: R.Tensor((3, 3), "float32") = lv5_adjoint
-                lv: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                lv2_adjoint: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (lv3_adjoint, lv)
-                lv7_adjoint: R.Tensor((3, 3), "float32") = lv5_adjoint
-                lv4_adjoint: R.Tensor((3, 3), "float32") = lv7_adjoint
-                lv11: R.Tensor((3, 3), "float32") = lv2_adjoint[0]
-                lv21: R.Tensor((3, 3), "float32") = R.add(lv11, lv4_adjoint)
-                lv31: R.Tensor((3, 3), "float32") = lv2_adjoint[1]
-                lv1_adjoint: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (lv21, lv31)
-                lv41: R.Tensor((3, 3), "float32") = lv1_adjoint[0]
-                x_adjoint: R.Tensor((3, 3), "float32") = R.add(lv7_adjoint, lv41)
-                y_adjoint: R.Tensor((3, 3), "float32") = lv1_adjoint[1]
-                R.output(gv, x_adjoint, y_adjoint)
-            return (gv, (x_adjoint, y_adjoint))
+                lv1: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (x, y)
+                lv2: R.Tensor((3, 3), dtype="float32") = lv1[0]
+                lv3: R.Tensor((3, 3), dtype="float32") = R.add(lv2, x)
+                lv4: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv1
+                lv5: R.Tensor((3, 3), dtype="float32") = lv4[0]
+                lv6: R.Tensor((3, 3), dtype="float32") = R.add(lv5, lv3)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv6, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -459,51 +466,66 @@ def test_tuple_nested():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tuple(R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")), R.Tensor((3, 3), "float32")), y: R.Tensor((3, 3), "float32"), z: R.Tensor((3, 3), "float32"), u: R.Tensor((3, 3), "float32")) -> R.Tensor((), "float32"):
+        def main_adjoint(x: R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32")), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32"), u: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dt [...]
             with R.dataflow():
-                lv1: R.Tuple(R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")), R.Tensor((3, 3), "float32")) = ((y, z), u)
-                lv2: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = x[0]
-                lv3: R.Tensor((3, 3), "float32") = lv2[0]
-                lv4: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = lv1[0]
-                lv5: R.Tensor((3, 3), "float32") = lv4[1]
-                lv6: R.Tensor((3, 3), "float32") = R.add(lv3, lv5)
-                lv7: R.Tensor((3, 3), "float32") = x[1]
-                lv8: R.Tensor((3, 3), "float32") = R.add(lv6, lv7)
-                gv: R.Tensor((), "float32") = R.sum(lv8, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv1: R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32")) = ((y, z), u)
+                lv2: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = x[0]
+                lv3: R.Tensor((3, 3), dtype="float32") = lv2[0]
+                lv4: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv1[0]
+                lv5: R.Tensor((3, 3), dtype="float32") = lv4[1]
+                lv6: R.Tensor((3, 3), dtype="float32") = R.add(lv3, lv5)
+                lv7: R.Tensor((3, 3), dtype="float32") = x[1]
+                lv8: R.Tensor((3, 3), dtype="float32") = R.add(lv6, lv7)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv8, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv8_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv6_adjoint: R.Tensor((3, 3), dtype="float32") = lv8_adjoint
+                lv7_adjoint: R.Tensor((3, 3), dtype="float32") = lv8_adjoint
+                lv: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv1_1: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                x_adjoint: R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32")) = ((lv, lv1_1), lv7_adjoint)
+                lv3_adjoint: R.Tensor((3, 3), dtype="float32") = lv6_adjoint
+                lv5_adjoint: R.Tensor((3, 3), dtype="float32") = lv6_adjoint
+                lv2_1: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv4_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (lv2_1, lv5_adjoint)
+                lv3_1: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv1_adjoint: R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32")) = (lv4_adjoint, lv3_1)
+                lv4_1: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv2_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (lv3_adjoint, lv4_1)
+                lv5_1: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = x_adjoint[0]
+                lv6_1: R.Tensor((3, 3), dtype="float32") = lv5_1[0]
+                lv7_1: R.Tensor((3, 3), dtype="float32") = lv2_adjoint[0]
+                lv8_1: R.Tensor((3, 3), dtype="float32") = R.add(lv6_1, lv7_1)
+                lv9: R.Tensor((3, 3), dtype="float32") = lv5_1[1]
+                lv10: R.Tensor((3, 3), dtype="float32") = lv2_adjoint[1]
+                lv11: R.Tensor((3, 3), dtype="float32") = R.add(lv9, lv10)
+                lv12: R.Tensor((3, 3), dtype="float32") = x_adjoint[1]
+                x_adjoint1: R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32")) = ((lv8_1, lv11), lv12)
+                lv13: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv1_adjoint[0]
+                y_adjoint: R.Tensor((3, 3), dtype="float32") = lv13[0]
+                z_adjoint: R.Tensor((3, 3), dtype="float32") = lv13[1]
+                u_adjoint: R.Tensor((3, 3), dtype="float32") = lv1_adjoint[1]
+                x_adjoint_out: R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32")) = x_adjoint1
+                y_adjoint_out: R.Tensor((3, 3), dtype="float32") = y_adjoint
+                z_adjoint_out: R.Tensor((3, 3), dtype="float32") = z_adjoint
+                u_adjoint_out: R.Tensor((3, 3), dtype="float32") = u_adjoint
+                R.output(gv, x_adjoint_out, y_adjoint_out, z_adjoint_out, u_adjoint_out)
+            return (gv, (x_adjoint_out, y_adjoint_out, z_adjoint_out, u_adjoint_out))
 
         @R.function
-        def main_adjoint(x: R.Tuple(R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")), R.Tensor((3, 3), "float32")), y: R.Tensor((3, 3), "float32"), z: R.Tensor((3, 3), "float32"), u: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tuple(R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")), R.Tensor((3, 3), "float32")), R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"))):
+        def main(x: R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32")), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32"), u: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tuple(R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")), R.Tensor((3, 3), "float32")) = ((y, z), u)
-                lv2: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = x[0]
-                lv3: R.Tensor((3, 3), "float32") = lv2[0]
-                lv4: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = lv1[0]
-                lv5: R.Tensor((3, 3), "float32") = lv4[1]
-                lv6: R.Tensor((3, 3), "float32") = R.add(lv3, lv5)
-                lv7: R.Tensor((3, 3), "float32") = x[1]
-                lv8: R.Tensor((3, 3), "float32") = R.add(lv6, lv7)
-                gv: R.Tensor((), "float32") = R.sum(lv8, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv8_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                lv7_adjoint: R.Tensor((3, 3), "float32") = lv8_adjoint
-                lv6_adjoint: R.Tensor((3, 3), "float32") = lv8_adjoint
-                lv5_adjoint: R.Tensor((3, 3), "float32") = lv6_adjoint
-                lv: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                lv4_adjoint: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (lv, lv5_adjoint)
-                lv3_adjoint: R.Tensor((3, 3), "float32") = lv6_adjoint
-                lv11: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                lv2_adjoint: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (lv3_adjoint, lv11)
-                lv21: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                lv1_adjoint: R.Tuple(R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")), R.Tensor((3, 3), "float32")) = (lv4_adjoint, lv21)
-                x_adjoint: R.Tuple(R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")), R.Tensor((3, 3), "float32")) = (lv2_adjoint, lv7_adjoint)
-                lv31: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = lv1_adjoint[0]
-                y_adjoint: R.Tensor((3, 3), "float32") = lv31[0]
-                z_adjoint: R.Tensor((3, 3), "float32") = lv31[1]
-                u_adjoint: R.Tensor((3, 3), "float32") = lv1_adjoint[1]
-                R.output(gv, x_adjoint, y_adjoint, z_adjoint, u_adjoint)
-            return (gv, (x_adjoint, y_adjoint, z_adjoint, u_adjoint))
+                lv1: R.Tuple(R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")), R.Tensor((3, 3), dtype="float32")) = ((y, z), u)
+                lv2: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = x[0]
+                lv3: R.Tensor((3, 3), dtype="float32") = lv2[0]
+                lv4: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv1[0]
+                lv5: R.Tensor((3, 3), dtype="float32") = lv4[1]
+                lv6: R.Tensor((3, 3), dtype="float32") = R.add(lv3, lv5)
+                lv7: R.Tensor((3, 3), dtype="float32") = x[1]
+                lv8: R.Tensor((3, 3), dtype="float32") = R.add(lv6, lv7)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv8, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -512,7 +534,6 @@ def test_tuple_nested():
 
 def test_tuple_update():
     """One tensor `x` is used in and out of tuple many times."""
-
     # fmt: off
     @I.ir_module
     class Before:
@@ -536,61 +557,66 @@ def test_tuple_update():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32")) -> R.Tensor((), "float32"):
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))):
             with R.dataflow():
-                lv0: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (x, y)
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, y)
-                lv2: R.Tensor((3, 3), "float32") = lv0[0]
-                lv3: R.Tensor((3, 3), "float32") = R.add(lv2, y)
-                lv4: R.Tensor((3, 3), "float32") = R.add(lv1, lv3)
-                lv5: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (x, y)
-                lv6: R.Tensor((3, 3), "float32") = lv5[0]
-                lv7: R.Tensor((3, 3), "float32") = lv0[0]
-                lv8: R.Tensor((3, 3), "float32") = R.add(lv4, lv6)
-                lv9: R.Tensor((3, 3), "float32") = R.add(lv8, lv7)
-                gv: R.Tensor((), "float32") = R.sum(lv9, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv0: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (x, y)
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, y)
+                lv2: R.Tensor((3, 3), dtype="float32") = lv0[0]
+                lv3: R.Tensor((3, 3), dtype="float32") = R.add(lv2, y)
+                lv4: R.Tensor((3, 3), dtype="float32") = R.add(lv1, lv3)
+                lv5: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (x, y)
+                lv6: R.Tensor((3, 3), dtype="float32") = lv5[0]
+                lv7: R.Tensor((3, 3), dtype="float32") = lv0[0]
+                lv8: R.Tensor((3, 3), dtype="float32") = R.add(lv4, lv6)
+                lv9: R.Tensor((3, 3), dtype="float32") = R.add(lv8, lv7)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv9, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv9_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv8_adjoint: R.Tensor((3, 3), dtype="float32") = lv9_adjoint
+                lv7_adjoint: R.Tensor((3, 3), dtype="float32") = lv9_adjoint
+                lv4_adjoint: R.Tensor((3, 3), dtype="float32") = lv8_adjoint
+                lv6_adjoint: R.Tensor((3, 3), dtype="float32") = lv8_adjoint
+                lv: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv0_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (lv7_adjoint, lv)
+                lv1_1: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv5_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (lv6_adjoint, lv1_1)
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = lv5_adjoint[0]
+                y_adjoint: R.Tensor((3, 3), dtype="float32") = lv5_adjoint[1]
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = lv4_adjoint
+                lv3_adjoint: R.Tensor((3, 3), dtype="float32") = lv4_adjoint
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = lv3_adjoint
+                y_adjoint1: R.Tensor((3, 3), dtype="float32") = R.add(y_adjoint, lv3_adjoint)
+                lv2_1: R.Tensor((3, 3), dtype="float32") = lv0_adjoint[0]
+                lv3_1: R.Tensor((3, 3), dtype="float32") = R.add(lv2_1, lv2_adjoint)
+                lv4_1: R.Tensor((3, 3), dtype="float32") = lv0_adjoint[1]
+                lv0_adjoint1: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (lv3_1, lv4_1)
+                x_adjoint1: R.Tensor((3, 3), dtype="float32") = R.add(x_adjoint, lv1_adjoint)
+                y_adjoint2: R.Tensor((3, 3), dtype="float32") = R.add(y_adjoint1, lv1_adjoint)
+                lv5_1: R.Tensor((3, 3), dtype="float32") = lv0_adjoint1[0]
+                x_adjoint2: R.Tensor((3, 3), dtype="float32") = R.add(x_adjoint1, lv5_1)
+                lv6_1: R.Tensor((3, 3), dtype="float32") = lv0_adjoint1[1]
+                y_adjoint3: R.Tensor((3, 3), dtype="float32") = R.add(y_adjoint2, lv6_1)
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint2
+                y_adjoint_out: R.Tensor((3, 3), dtype="float32") = y_adjoint3
+                R.output(gv, x_adjoint_out, y_adjoint_out)
+            return (gv, (x_adjoint_out, y_adjoint_out))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"))):
+        def main(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv0: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (x, y)
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, y)
-                lv2: R.Tensor((3, 3), "float32") = lv0[0]
-                lv3: R.Tensor((3, 3), "float32") = R.add(lv2, y)
-                lv4: R.Tensor((3, 3), "float32") = R.add(lv1, lv3)
-                lv5: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (x, y)
-                lv6: R.Tensor((3, 3), "float32") = lv5[0]
-                lv7: R.Tensor((3, 3), "float32") = lv0[0]
-                lv8: R.Tensor((3, 3), "float32") = R.add(lv4, lv6)
-                lv9: R.Tensor((3, 3), "float32") = R.add(lv8, lv7)
-                gv: R.Tensor((), "float32") = R.sum(lv9, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv9_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                lv8_adjoint: R.Tensor((3, 3), "float32") = lv9_adjoint
-                lv7_adjoint: R.Tensor((3, 3), "float32") = lv9_adjoint
-                lv6_adjoint: R.Tensor((3, 3), "float32") = lv8_adjoint
-                lv: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                lv5_adjoint: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (lv6_adjoint, lv)
-                lv4_adjoint: R.Tensor((3, 3), "float32") = lv8_adjoint
-                lv3_adjoint: R.Tensor((3, 3), "float32") = lv4_adjoint
-                lv2_adjoint: R.Tensor((3, 3), "float32") = lv3_adjoint
-                lv1_adjoint: R.Tensor((3, 3), "float32") = lv4_adjoint
-                lv11: R.Tensor((3, 3), "float32") = R.add(lv7_adjoint, lv2_adjoint)
-                lv21: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                lv0_adjoint: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (lv11, lv21)
-                lv31: R.Tensor((3, 3), "float32") = lv5_adjoint[0]
-                lv41: R.Tensor((3, 3), "float32") = R.add(lv31, lv1_adjoint)
-                lv51: R.Tensor((3, 3), "float32") = lv0_adjoint[0]
-                x_adjoint: R.Tensor((3, 3), "float32") = R.add(lv41, lv51)
-                lv61: R.Tensor((3, 3), "float32") = lv5_adjoint[1]
-                lv71: R.Tensor((3, 3), "float32") = R.add(lv61, lv3_adjoint)
-                lv81: R.Tensor((3, 3), "float32") = R.add(lv71, lv1_adjoint)
-                lv91: R.Tensor((3, 3), "float32") = lv0_adjoint[1]
-                y_adjoint: R.Tensor((3, 3), "float32") = R.add(lv81, lv91)
-                R.output(gv, x_adjoint, y_adjoint)
-            return (gv, (x_adjoint, y_adjoint))
+                lv0: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (x, y)
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, y)
+                lv2: R.Tensor((3, 3), dtype="float32") = lv0[0]
+                lv3: R.Tensor((3, 3), dtype="float32") = R.add(lv2, y)
+                lv4: R.Tensor((3, 3), dtype="float32") = R.add(lv1, lv3)
+                lv5: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (x, y)
+                lv6: R.Tensor((3, 3), dtype="float32") = lv5[0]
+                lv7: R.Tensor((3, 3), dtype="float32") = lv0[0]
+                lv8: R.Tensor((3, 3), dtype="float32") = R.add(lv4, lv6)
+                lv9: R.Tensor((3, 3), dtype="float32") = R.add(lv8, lv7)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv9, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -613,26 +639,27 @@ def test_tuple_op_simple():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((6,), "float32")) -> R.Tensor((), "float32"):
+        def main_adjoint(x: R.Tensor((6,), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((6,), dtype="float32"))):
             with R.dataflow():
-                lv1: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")) = R.split(x, indices_or_sections=2, axis=0)
-                lv2: R.Tensor((6,), "float32") = R.concat(lv1, axis=0)
-                gv: R.Tensor((), "float32") = R.sum(lv2, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv1: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = R.split(x, indices_or_sections=2, axis=0)
+                lv2: R.Tensor((6,), dtype="float32") = R.concat(lv1, axis=0)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv2, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv2_adjoint: R.Tensor((6,), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([6]))
+                lv1_adjoint: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = R.split(lv2_adjoint, indices_or_sections=[3], axis=0)
+                x_adjoint: R.Tensor((6,), dtype="float32") = R.concat(lv1_adjoint, axis=0)
+                x_adjoint_out: R.Tensor((6,), dtype="float32") = x_adjoint
+                R.output(gv, x_adjoint_out)
+            return (gv, (x_adjoint_out,))
 
         @R.function
-        def main_adjoint(x: R.Tensor((6,), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((6,), "float32"))):
+        def main(x: R.Tensor((6,), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")) = R.split(x, indices_or_sections=2, axis=0)
-                lv2: R.Tensor((6,), "float32") = R.concat(lv1, axis=0)
-                gv: R.Tensor((), "float32") = R.sum(lv2, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv2_adjoint: R.Tensor((6,), "float32") = R.broadcast_to(gv_adjoint, (6,))
-                lv1_adjoint: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")) = R.split(lv2_adjoint, indices_or_sections=[3], axis=0)
-                x_adjoint: R.Tensor((6,), "float32") = R.concat(lv1_adjoint, axis=0)
-                R.output(gv, x_adjoint)
-            return (gv, (x_adjoint,))
+                lv1: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = R.split(x, indices_or_sections=2, axis=0)
+                lv2: R.Tensor((6,), dtype="float32") = R.concat(lv1, axis=0)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv2, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -659,46 +686,48 @@ def test_tuple_op_construct():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((3,), "float32"), y: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32"))) -> R.Tensor((), "float32"):
+        def main_adjoint(x: R.Tensor((3,), dtype="float32"), y: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32"))) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3,), dtype="float32"), R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")))):
             with R.dataflow():
-                lv1: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")) = (x, x)
-                lv2: R.Tensor((6,), "float32") = R.concat(lv1, axis=0)
-                lv3: R.Tensor((6,), "float32") = R.concat((x, x), axis=0)
-                lv4: R.Tensor((6,), "float32") = R.concat(y, axis=0)
-                lv5: R.Tensor((6,), "float32") = R.add(lv2, lv3)
-                lv6: R.Tensor((6,), "float32") = R.add(lv5, lv4)
-                gv: R.Tensor((), "float32") = R.sum(lv6, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv1: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = (x, x)
+                lv2: R.Tensor((6,), dtype="float32") = R.concat(lv1, axis=0)
+                lv3: R.Tensor((6,), dtype="float32") = R.concat((x, x), axis=0)
+                lv4: R.Tensor((6,), dtype="float32") = R.concat(y, axis=0)
+                lv5: R.Tensor((6,), dtype="float32") = R.add(lv2, lv3)
+                lv6: R.Tensor((6,), dtype="float32") = R.add(lv5, lv4)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv6, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv6_adjoint: R.Tensor((6,), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([6]))
+                lv5_adjoint: R.Tensor((6,), dtype="float32") = lv6_adjoint
+                lv4_adjoint: R.Tensor((6,), dtype="float32") = lv6_adjoint
+                lv2_adjoint: R.Tensor((6,), dtype="float32") = lv5_adjoint
+                lv3_adjoint: R.Tensor((6,), dtype="float32") = lv5_adjoint
+                y_adjoint: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = R.split(lv4_adjoint, indices_or_sections=[3], axis=0)
+                lv: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = R.split(lv3_adjoint, indices_or_sections=[3], axis=0)
+                x_adjoint: R.Tensor((3,), dtype="float32") = lv[0]
+                lv1_1: R.Tensor((3,), dtype="float32") = lv[1]
+                x_adjoint1: R.Tensor((3,), dtype="float32") = R.add(x_adjoint, lv1_1)
+                lv1_adjoint: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = R.split(lv2_adjoint, indices_or_sections=[3], axis=0)
+                lv2_1: R.Tensor((3,), dtype="float32") = lv1_adjoint[0]
+                x_adjoint2: R.Tensor((3,), dtype="float32") = R.add(x_adjoint1, lv2_1)
+                lv3_1: R.Tensor((3,), dtype="float32") = lv1_adjoint[1]
+                x_adjoint3: R.Tensor((3,), dtype="float32") = R.add(x_adjoint2, lv3_1)
+                x_adjoint_out: R.Tensor((3,), dtype="float32") = x_adjoint3
+                y_adjoint_out: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = y_adjoint
+                R.output(gv, x_adjoint_out, y_adjoint_out)
+            return (gv, (x_adjoint_out, y_adjoint_out))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3,), "float32"), y: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32"))) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((3,), "float32"), R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")))):
+        def main(x: R.Tensor((3,), dtype="float32"), y: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32"))) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")) = (x, x)
-                lv2: R.Tensor((6,), "float32") = R.concat(lv1, axis=0)
-                lv3: R.Tensor((6,), "float32") = R.concat((x, x), axis=0)
-                lv4: R.Tensor((6,), "float32") = R.concat(y, axis=0)
-                lv5: R.Tensor((6,), "float32") = R.add(lv2, lv3)
-                lv6: R.Tensor((6,), "float32") = R.add(lv5, lv4)
-                gv: R.Tensor((), "float32") = R.sum(lv6, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv6_adjoint: R.Tensor((6,), "float32") = R.broadcast_to(gv_adjoint, (6,))
-                lv5_adjoint: R.Tensor((6,), "float32") = lv6_adjoint
-                lv4_adjoint: R.Tensor((6,), "float32") = lv6_adjoint
-                lv3_adjoint: R.Tensor((6,), "float32") = lv5_adjoint
-                lv2_adjoint: R.Tensor((6,), "float32") = lv5_adjoint
-                lv1_adjoint: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")) = R.split(lv2_adjoint, indices_or_sections=[3], axis=0)
-                lv: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")) = R.split(lv3_adjoint, indices_or_sections=[3], axis=0)
-                lv11: R.Tensor((3,), "float32") = lv[0]
-                lv21: R.Tensor((3,), "float32") = lv[1]
-                lv31: R.Tensor((3,), "float32") = R.add(lv11, lv21)
-                lv41: R.Tensor((3,), "float32") = lv1_adjoint[0]
-                lv51: R.Tensor((3,), "float32") = R.add(lv31, lv41)
-                lv61: R.Tensor((3,), "float32") = lv1_adjoint[1]
-                x_adjoint: R.Tensor((3,), "float32") = R.add(lv51, lv61)
-                y_adjoint: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")) = R.split(lv4_adjoint, indices_or_sections=[3], axis=0)
-                R.output(gv, x_adjoint, y_adjoint)
-            return (gv, (x_adjoint, y_adjoint))
+                lv1: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = (x, x)
+                lv2: R.Tensor((6,), dtype="float32") = R.concat(lv1, axis=0)
+                lv3: R.Tensor((6,), dtype="float32") = R.concat((x, x), axis=0)
+                lv4: R.Tensor((6,), dtype="float32") = R.concat(y, axis=0)
+                lv5: R.Tensor((6,), dtype="float32") = R.add(lv2, lv3)
+                lv6: R.Tensor((6,), dtype="float32") = R.add(lv5, lv4)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv6, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -728,43 +757,41 @@ def test_tuple_op_const():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((3,), "float32")) -> R.Tensor((), "float32"):
-            # block 0
+        def main_adjoint(x: R.Tensor((3,), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3,), dtype="float32"))):
             with R.dataflow():
-                lv1: R.Tensor((6,), "float32") = R.concat((c1, c2), axis=0)
-                lv2: R.Tensor((6,), "float32") = R.concat((c3, x), axis=0)
-                lv3: R.Tensor((6,), "float32") = R.concat((x, x), axis=0)
-                lv4: R.Tensor((6,), "float32") = R.add(lv1, lv2)
-                lv5: R.Tensor((6,), "float32") = R.add(lv4, lv3)
-                gv: R.Tensor((), "float32") = R.sum(lv5, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
+                lv1: R.Tensor((6,), dtype="float32") = R.concat((c1, c2), axis=0)
+                lv2: R.Tensor((6,), dtype="float32") = R.concat((c3, x), axis=0)
+                lv3: R.Tensor((6,), dtype="float32") = R.concat((x, x), axis=0)
+                lv4: R.Tensor((6,), dtype="float32") = R.add(lv1, lv2)
+                lv5: R.Tensor((6,), dtype="float32") = R.add(lv4, lv3)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv5, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv5_adjoint: R.Tensor((6,), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([6]))
+                lv4_adjoint: R.Tensor((6,), dtype="float32") = lv5_adjoint
+                lv3_adjoint: R.Tensor((6,), dtype="float32") = lv5_adjoint
+                lv2_adjoint: R.Tensor((6,), dtype="float32") = lv4_adjoint
+                lv: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = R.split(lv3_adjoint, indices_or_sections=[3], axis=0)
+                x_adjoint: R.Tensor((3,), dtype="float32") = lv[0]
+                lv1_1: R.Tensor((3,), dtype="float32") = lv[1]
+                x_adjoint1: R.Tensor((3,), dtype="float32") = R.add(x_adjoint, lv1_1)
+                lv2_1: R.Tuple(R.Tensor((3,), dtype="float32"), R.Tensor((3,), dtype="float32")) = R.split(lv2_adjoint, indices_or_sections=[3], axis=0)
+                lv3_1: R.Tensor((3,), dtype="float32") = lv2_1[1]
+                x_adjoint2: R.Tensor((3,), dtype="float32") = R.add(x_adjoint1, lv3_1)
+                x_adjoint_out: R.Tensor((3,), dtype="float32") = x_adjoint2
+                R.output(gv, x_adjoint_out)
+            return (gv, (x_adjoint_out,))
 
         @R.function
-        def main_adjoint(x: R.Tensor((3,), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((3,), "float32"))):
-            # block 0
+        def main(x: R.Tensor((3,), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tensor((6,), "float32") = R.concat((c1, c2), axis=0)
-                lv2: R.Tensor((6,), "float32") = R.concat((c3, x), axis=0)
-                lv3: R.Tensor((6,), "float32") = R.concat((x, x), axis=0)
-                lv4: R.Tensor((6,), "float32") = R.add(lv1, lv2)
-                lv5: R.Tensor((6,), "float32") = R.add(lv4, lv3)
-                gv: R.Tensor((), "float32") = R.sum(lv5, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv5_adjoint: R.Tensor((6,), "float32") = R.broadcast_to(gv_adjoint, (6,))
-                lv4_adjoint: R.Tensor((6,), "float32") = lv5_adjoint
-                lv3_adjoint: R.Tensor((6,), "float32") = lv5_adjoint
-                lv2_adjoint: R.Tensor((6,), "float32") = lv4_adjoint
-                lv1_adjoint: R.Tensor((6,), "float32") = lv4_adjoint
-                lv: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")) = R.split(lv3_adjoint, indices_or_sections=[3], axis=0)
-                lv11: R.Tensor((3,), "float32") = lv[0]
-                lv21: R.Tensor((3,), "float32") = lv[1]
-                lv31: R.Tensor((3,), "float32") = R.add(lv11, lv21)
-                lv41: R.Tuple(R.Tensor((3,), "float32"), R.Tensor((3,), "float32")) = R.split(lv2_adjoint, indices_or_sections=[3], axis=0)
-                lv51: R.Tensor((3,), "float32") = lv41[1]
-                x_adjoint: R.Tensor((3,), "float32") = R.add(lv31, lv51)
-                R.output(gv, x_adjoint)
-            return (gv, (x_adjoint,))
+                lv1: R.Tensor((6,), dtype="float32") = R.concat((c1, c2), axis=0)
+                lv2: R.Tensor((6,), dtype="float32") = R.concat((c3, x), axis=0)
+                lv3: R.Tensor((6,), dtype="float32") = R.concat((x, x), axis=0)
+                lv4: R.Tensor((6,), dtype="float32") = R.add(lv1, lv2)
+                lv5: R.Tensor((6,), dtype="float32") = R.add(lv4, lv3)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv5, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -794,42 +821,85 @@ def test_const():
     @I.ir_module
     class Expected:
         @R.function
-        def main(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32")) -> R.Tensor((), "float32"):
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))):
+            with R.dataflow():
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, cst)
+                lv2: R.Tensor((3, 3), dtype="float32") = cst
+                lv3: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))) = (cst, (cst, lv1))
+                lv4: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv3[1]
+                lv5: R.Tensor((3, 3), dtype="float32") = lv4[1]
+                lv6: R.Tensor((3, 3), dtype="float32") = R.subtract(lv5, lv2)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv6, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv6_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv5_adjoint: R.Tensor((3, 3), dtype="float32") = lv6_adjoint
+                lv: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv4_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = (lv, lv5_adjoint)
+                lv1_1: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv3_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))) = (lv1_1, lv4_adjoint)
+                lv2_1: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv3_adjoint[1]
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = lv2_1[1]
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = lv1_adjoint
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint
+                y_adjoint: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                y_adjoint_out: R.Tensor((3, 3), dtype="float32") = y_adjoint
+                R.output(gv, x_adjoint_out, y_adjoint_out)
+            return (gv, (x_adjoint_out, y_adjoint_out))
+
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
+            with R.dataflow():
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(x, cst)
+                lv2: R.Tensor((3, 3), dtype="float32") = cst
+                lv3: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))) = (cst, (cst, lv1))
+                lv4: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv3[1]
+                lv5: R.Tensor((3, 3), dtype="float32") = lv4[1]
+                lv6: R.Tensor((3, 3), dtype="float32") = R.subtract(lv5, lv2)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv6, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
+    # fmt: on
+
+    After = relax.transform.Gradient("main")(Before)
+    assert_structural_equal(After, Expected)
+
+
+def test_shape_expr():
+    # fmt: off
+    @I.ir_module
+    class Before:
+        @R.function
+        def main(x: R.Tensor((3, 4), "float32")):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, cst)
-                lv2: R.Tensor((3, 3), "float32") = cst
-                lv3: R.Tuple(R.Tensor((3, 3), "float32"), R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"))) = (cst, (cst, lv1))
-                lv4: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = lv3[1]
-                lv5: R.Tensor((3, 3), "float32") = lv4[1]
-                lv6: R.Tensor((3, 3), "float32") = R.subtract(lv5, lv2)
-                gv: R.Tensor((), "float32") = R.sum(lv6, axis=None, keepdims=False)
+                s = R.shape([3, 2, 2])
+                lv = R.reshape(x, s)
+                gv = R.sum(lv)
                 R.output(gv)
             return gv
 
+    @I.ir_module
+    class Expected:
+        @R.function
+        def main_adjoint(x: R.Tensor((3, 4), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 4), dtype="float32"))):
+            with R.dataflow():
+                s: R.Shape([3, 2, 2]) = R.shape([3, 2, 2])
+                lv: R.Tensor((3, 2, 2), dtype="float32") = R.reshape(x, s)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv_adjoint: R.Tensor((3, 2, 2), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 2, 2]))
+                x_adjoint: R.Tensor((3, 4), dtype="float32") = R.reshape(lv_adjoint, R.shape([3, 4]))
+                x_adjoint_out: R.Tensor((3, 4), dtype="float32") = x_adjoint
+                R.output(gv, x_adjoint_out)
+            return (gv, (x_adjoint_out,))
+
         @R.function
-        def main_adjoint(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32")) -> R.Tuple(R.Tensor((), "float32"), R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"))):
+        def main(x: R.Tensor((3, 4), dtype="float32")) -> R.Tensor((), dtype="float32"):
             with R.dataflow():
-                lv1: R.Tensor((3, 3), "float32") = R.add(x, cst)
-                lv2: R.Tensor((3, 3), "float32") = cst
-                lv3: R.Tuple(R.Tensor((3, 3), "float32"), R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"))) = (cst, (cst, lv1))
-                lv4: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = lv3[1]
-                lv5: R.Tensor((3, 3), "float32") = lv4[1]
-                lv6: R.Tensor((3, 3), "float32") = R.subtract(lv5, lv2)
-                gv: R.Tensor((), "float32") = R.sum(lv6, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), "float32") = R.ones((), "float32")
-                lv6_adjoint: R.Tensor((3, 3), "float32") = R.broadcast_to(gv_adjoint, (3, 3))
-                lv5_adjoint: R.Tensor((3, 3), "float32") = lv6_adjoint
-                lv: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                lv4_adjoint: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = (lv, lv5_adjoint)
-                lv11: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                lv3_adjoint: R.Tuple(R.Tensor((3, 3), "float32"), R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32"))) = (lv11, lv4_adjoint)
-                lv2_adjoint: R.Tensor((3, 3), "float32") = R.negative(lv6_adjoint)
-                lv21: R.Tuple(R.Tensor((3, 3), "float32"), R.Tensor((3, 3), "float32")) = lv3_adjoint[1]
-                lv1_adjoint: R.Tensor((3, 3), "float32") = lv21[1]
-                x_adjoint: R.Tensor((3, 3), "float32") = lv1_adjoint
-                y_adjoint: R.Tensor((3, 3), "float32") = R.zeros((3, 3), "float32")
-                R.output(gv, x_adjoint, y_adjoint)
-            return (gv, (x_adjoint, y_adjoint))
+                s: R.Shape([3, 2, 2]) = R.shape([3, 2, 2])
+                lv: R.Tensor((3, 2, 2), dtype="float32") = R.reshape(x, s)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
     # fmt: on
 
     After = relax.transform.Gradient("main")(Before)
@@ -890,6 +960,29 @@ def test_function_copy():
     old_bindings_len = len(old_bindings)
     new_bindings = After["main_adjoint"].body.blocks[0].bindings[:old_bindings_len]
     assert_structural_equal(old_bindings, new_bindings, True)
+    assert relax.analysis.well_formed(After)
+
+
+def test_tir_copy():
+    @I.ir_module
+    class Before:
+        @R.function
+        def main(
+            x0: R.Tensor(("n", "n"), "float32"),
+            x1: R.Tensor(("n", "n"), "float32"),
+            x2: R.Tensor(("n", "n"), "float32"),
+            x3: R.Tensor(("n", "n"), "float32"),
+        ):
+            with R.dataflow():
+                lv0 = R.add(x0, x1)
+                lv1 = R.add(x2, x3)
+                lv2 = R.add(lv0, lv1)
+                gv = R.sum(lv2)
+                R.output(gv)
+            return gv
+
+    After = relax.transform.Gradient("main")(Before)
+    assert relax.analysis.well_formed(After)
 
 
 def test_report_error():
@@ -1055,47 +1148,6 @@ def test_report_error():
         relax.transform.Gradient("main")(IntDtypeTuple)
 
 
-def test_shape_expr():
-    # fmt: off
-    @I.ir_module
-    class Before:
-        @R.function
-        def main(x: R.Tensor((3, 4), "float32")):
-            with R.dataflow():
-                s = R.shape([3, 2, 2])
-                lv = R.reshape(x, s)
-                gv = R.sum(lv)
-                R.output(gv)
-            return gv
-
-    @I.ir_module
-    class Expected:
-        @R.function
-        def main_adjoint(x: R.Tensor((3, 4), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 4), dtype="float32"))):
-            with R.dataflow():
-                s: R.Shape([3, 2, 2]) = R.shape([3, 2, 2])
-                lv = R.reshape(x, s)
-                gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
-                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
-                lv_adjoint : R.Tensor([3, 2, 2], "float32") = R.broadcast_to(gv_adjoint, R.shape([3, 2, 2]))
-                x_adjoint: R.Tensor((3, 4), dtype="float32") = R.reshape(lv_adjoint, R.shape([3, 4]))
-                R.output(gv, x_adjoint)
-            return (gv, (x_adjoint,))
-
-        @R.function
-        def main(x: R.Tensor((3, 4), dtype="float32")) -> R.Tensor((), dtype="float32"):
-            with R.dataflow():
-                s: R.Shape([3, 2, 2]) = R.shape([3, 2, 2])
-                lv = R.reshape(x, s)
-                gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
-                R.output(gv)
-            return gv
-    # fmt: on
-
-    After = relax.transform.Gradient("main")(Before)
-    assert_structural_equal(After, Expected)
-
-
 def test_mlp_script():
     """
     An example of single layer multi-layer perceptron. You can add extra layers if you want.
@@ -1133,17 +1185,18 @@ def test_mlp_script():
                 lv: R.Tensor((), dtype="float32") = R.divide(loss_adjoint, R.const(3, "float32"))
                 lv1: R.Tensor((), dtype="float32") = R.negative(lv)
                 logits_adjoint: R.Tensor((3, 5), dtype="float32") = R.multiply(lv1, label)
-                lv2: R.Tensor((3, 1), dtype="float32") = R.sum(logits_adjoint, axis=[-1], keepdims=True)
-                lv3: R.Tensor((3, 5), dtype="float32") = R.exp(logits)
-                lv4: R.Tensor((3, 5), dtype="float32") = R.multiply(lv2, lv3)
-                out_adjoint: R.Tensor((3, 5), dtype="float32") = R.subtract(logits_adjoint, lv4)
+                lv3: R.Tensor((3, 1), dtype="float32") = R.sum(logits_adjoint, axis=[-1], keepdims=True)
+                lv4: R.Tensor((3, 5), dtype="float32") = R.exp(logits)
+                lv5: R.Tensor((3, 5), dtype="float32") = R.multiply(lv3, lv4)
+                out_adjoint: R.Tensor((3, 5), dtype="float32") = R.subtract(logits_adjoint, lv5)
                 lv0_adjoint: R.Tensor((3, 5), dtype="float32") = out_adjoint
-                lv5: R.Tensor((5, 10), dtype="float32") = R.permute_dims(w0, axes=[1, 0])
-                lv6: R.Tensor((10, 3), dtype="float32") = R.permute_dims(x, axes=[1, 0])
-                w0_adjoint: R.Tensor((10, 5), dtype="float32") = R.matmul(lv6, lv0_adjoint, out_dtype="void")
                 b0_adjoint: R.Tensor((5,), dtype="float32") = R.collapse_sum_to(out_adjoint, R.shape([5]))
-                R.output(loss, w0_adjoint, b0_adjoint)
-            return (loss, (w0_adjoint, b0_adjoint))
+                lv7: R.Tensor((10, 3), dtype="float32") = R.permute_dims(x, axes=[1, 0])
+                w0_adjoint: R.Tensor((10, 5), dtype="float32") = R.matmul(lv7, lv0_adjoint, out_dtype="void")
+                w0_adjoint_out: R.Tensor((10, 5), dtype="float32") = w0_adjoint
+                b0_adjoint_out: R.Tensor((5,), dtype="float32") = b0_adjoint
+                R.output(loss, w0_adjoint_out, b0_adjoint_out)
+            return (loss, (w0_adjoint_out, b0_adjoint_out))
 
         @R.function
         def main(x: R.Tensor((3, 10), dtype="float32"), w0: R.Tensor((10, 5), dtype="float32"), b0: R.Tensor((5,), dtype="float32"), label: R.Tensor((3, 5), dtype="float32")) -> R.Tensor((), dtype="float32"):
diff --git a/tests/python/relax/test_transform_gradient_checkpoint.py b/tests/python/relax/test_transform_gradient_checkpoint.py
new file mode 100644
index 0000000000..3e94125b77
--- /dev/null
+++ b/tests/python/relax/test_transform_gradient_checkpoint.py
@@ -0,0 +1,689 @@
+# 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.
+"""Unit tests for gradient with checkpointing."""
+import tvm
+from tvm.relax.block_builder import BlockBuilder
+from tvm.relax.testing.nn import checkpoint, emit_checkpoint, emit_checkpoint_sequential
+import tvm.testing
+from tvm import relax
+from tvm.ir.base import assert_structural_equal
+from tvm.script.parser import relax as R, ir as I
+
+
+def test_sequential():
+    """Comp. graph is a sequence"""
+    # fmt: off
+    @I.ir_module
+    class Before:
+        @R.function
+        def main(x: R.Tensor((3, 3), "float32")):
+            with R.dataflow():
+                x_scp = R.grad.start_checkpoint(x)
+                lv1 = R.power(x_scp, R.const(3, "float32"))
+                lv1_ecp = R.grad.end_checkpoint(lv1)
+                lv2 = R.power(lv1_ecp, R.const(3, "float32"))
+                lv2_scp = R.grad.start_checkpoint(lv2)
+                lv3 = R.power(lv2_scp, R.const(3, "float32"))
+                lv4 = R.power(lv3, R.const(3, "float32"))
+                gv = R.sum(lv4)
+                gv_ecp = R.grad.end_checkpoint(gv)
+                R.output(gv_ecp)
+            return gv_ecp
+
+    @I.ir_module
+    class Expected:
+        @R.function
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"))):
+            with R.dataflow():
+                lv1: R.Tensor((3, 3), dtype="float32") = R.power(x, R.const(3, "float32"))
+                lv2: R.Tensor((3, 3), dtype="float32") = R.power(lv1, R.const(3, "float32"))
+                lv3: R.Tensor((3, 3), dtype="float32") = R.power(lv2, R.const(3, "float32"))
+                lv4: R.Tensor((3, 3), dtype="float32") = R.power(lv3, R.const(3, "float32"))
+                gv: R.Tensor((), dtype="float32") = R.sum(lv4, axis=None, keepdims=False)
+                gv_1: R.Tensor((), dtype="float32") = gv
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                gv_adjoint1: R.Tensor((), dtype="float32") = gv_adjoint
+                lv3_cp: R.Tensor((3, 3), dtype="float32") = R.power(lv2, R.const(3, "float32"))
+                lv4_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint1, R.shape([3, 3]))
+                lv: R.Tensor((3, 3), dtype="float32") = R.multiply(lv4_adjoint, R.const(3, "float32"))
+                lv1_1: R.Tensor((), dtype="float32") = R.subtract(R.const(3, "float32"), R.const(1, "float32"))
+                lv2_1: R.Tensor((3, 3), dtype="float32") = R.power(lv3_cp, lv1_1)
+                lv3_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv, lv2_1)
+                lv6: R.Tensor((3, 3), dtype="float32") = R.multiply(lv3_adjoint, R.const(3, "float32"))
+                lv7: R.Tensor((), dtype="float32") = R.subtract(R.const(3, "float32"), R.const(1, "float32"))
+                lv8: R.Tensor((3, 3), dtype="float32") = R.power(lv2, lv7)
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv6, lv8)
+                lv1_cp: R.Tensor((3, 3), dtype="float32") = R.power(x, R.const(3, "float32"))
+                lv12: R.Tensor((3, 3), dtype="float32") = R.multiply(lv2_adjoint, R.const(3, "float32"))
+                lv13: R.Tensor((), dtype="float32") = R.subtract(R.const(3, "float32"), R.const(1, "float32"))
+                lv14: R.Tensor((3, 3), dtype="float32") = R.power(lv1_cp, lv13)
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv12, lv14)
+                lv18: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1_adjoint, R.const(3, "float32"))
+                lv19: R.Tensor((), dtype="float32") = R.subtract(R.const(3, "float32"), R.const(1, "float32"))
+                lv20: R.Tensor((3, 3), dtype="float32") = R.power(x, lv19)
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv18, lv20)
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint
+                R.output(gv_1, x_adjoint_out)
+            return (gv_1, (x_adjoint_out,))
+
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
+            with R.dataflow():
+                x_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(x)
+                lv1: R.Tensor((3, 3), dtype="float32") = R.power(x_scp, R.const(3, "float32"))
+                lv1_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv1)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.power(lv1_ecp, R.const(3, "float32"))
+                lv2_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(lv2)
+                lv3: R.Tensor((3, 3), dtype="float32") = R.power(lv2_scp, R.const(3, "float32"))
+                lv4: R.Tensor((3, 3), dtype="float32") = R.power(lv3, R.const(3, "float32"))
+                gv: R.Tensor((), dtype="float32") = R.sum(lv4, axis=None, keepdims=False)
+                gv_ecp: R.Tensor((), dtype="float32") = R.grad.end_checkpoint(gv)
+                R.output(gv_ecp)
+            return gv_ecp
+    # fmt: on
+
+    After = relax.transform.Gradient("main")(Before)
+    assert_structural_equal(After, Expected)
+
+
+def test_sequential_consecutive():
+    """Comp. graph is a sequence"""
+    # fmt: off
+    @I.ir_module
+    class Before:
+        @R.function
+        def main(x: R.Tensor((3, 3), "float32")):
+            with R.dataflow():
+                x_scp = R.grad.start_checkpoint(x)
+                lv1 = R.power(x_scp, R.const(3, "float32"))
+                lv2 = R.power(lv1, R.const(3, "float32"))
+                lv2_ecp = R.grad.end_checkpoint(lv2)
+                lv2_scp = R.grad.start_checkpoint(lv2_ecp)
+                lv3 = R.power(lv2_scp, R.const(3, "float32"))
+                lv4 = R.power(lv3, R.const(3, "float32"))
+                lv4_ecp = R.grad.end_checkpoint(lv4)
+                gv = R.sum(lv4_ecp)
+                R.output(gv)
+            return gv
+
+    @I.ir_module
+    class Expected:
+        @R.function
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"))):
+            with R.dataflow():
+                lv1: R.Tensor((3, 3), dtype="float32") = R.power(x, R.const(3, "float32"))
+                lv2: R.Tensor((3, 3), dtype="float32") = R.power(lv1, R.const(3, "float32"))
+                lv3: R.Tensor((3, 3), dtype="float32") = R.power(lv2, R.const(3, "float32"))
+                lv4: R.Tensor((3, 3), dtype="float32") = R.power(lv3, R.const(3, "float32"))
+                gv: R.Tensor((), dtype="float32") = R.sum(lv4, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv3_cp: R.Tensor((3, 3), dtype="float32") = R.power(lv2, R.const(3, "float32"))
+                lv4_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv: R.Tensor((3, 3), dtype="float32") = R.multiply(lv4_adjoint, R.const(3, "float32"))
+                lv1_1: R.Tensor((), dtype="float32") = R.subtract(R.const(3, "float32"), R.const(1, "float32"))
+                lv2_1: R.Tensor((3, 3), dtype="float32") = R.power(lv3_cp, lv1_1)
+                lv3_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv, lv2_1)
+                lv6: R.Tensor((3, 3), dtype="float32") = R.multiply(lv3_adjoint, R.const(3, "float32"))
+                lv7: R.Tensor((), dtype="float32") = R.subtract(R.const(3, "float32"), R.const(1, "float32"))
+                lv8: R.Tensor((3, 3), dtype="float32") = R.power(lv2, lv7)
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv6, lv8)
+                lv1_cp: R.Tensor((3, 3), dtype="float32") = R.power(x, R.const(3, "float32"))
+                lv12: R.Tensor((3, 3), dtype="float32") = R.multiply(lv2_adjoint, R.const(3, "float32"))
+                lv13: R.Tensor((), dtype="float32") = R.subtract(R.const(3, "float32"), R.const(1, "float32"))
+                lv14: R.Tensor((3, 3), dtype="float32") = R.power(lv1_cp, lv13)
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv12, lv14)
+                lv18: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1_adjoint, R.const(3, "float32"))
+                lv19: R.Tensor((), dtype="float32") = R.subtract(R.const(3, "float32"), R.const(1, "float32"))
+                lv20: R.Tensor((3, 3), dtype="float32") = R.power(x, lv19)
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv18, lv20)
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint
+                R.output(gv, x_adjoint_out)
+            return (gv, (x_adjoint_out,))
+
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
+            with R.dataflow():
+                x_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(x)
+                lv1: R.Tensor((3, 3), dtype="float32") = R.power(x_scp, R.const(3, "float32"))
+                lv2: R.Tensor((3, 3), dtype="float32") = R.power(lv1, R.const(3, "float32"))
+                lv2_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv2)
+                lv2_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(lv2_ecp)
+                lv3: R.Tensor((3, 3), dtype="float32") = R.power(lv2_scp, R.const(3, "float32"))
+                lv4: R.Tensor((3, 3), dtype="float32") = R.power(lv3, R.const(3, "float32"))
+                lv4_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv4)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv4_ecp, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
+
+    # fmt: on
+
+    After = relax.transform.Gradient("main")(Before)
+    assert_structural_equal(After, Expected)
+
+
+def test_tuple():
+    """Comp. graph is a sequence"""
+    # fmt: off
+    @I.ir_module
+    class Before:
+        @R.function
+        def main(x: R.Tensor((3, 3), "float32")):
+            with R.dataflow():
+                x_scp = R.grad.start_checkpoint(x)
+                lv1 = R.power(x_scp, R.const(3, "float32"))
+                lv2 = (x, lv1)
+                lv3 = lv2
+                lv4 = R.power(lv3[0], R.const(3, "float32"))
+                lv4_ecp = R.grad.end_checkpoint(lv4)
+                gv = R.sum(lv4_ecp)
+                R.output(gv)
+            return gv
+
+    @I.ir_module
+    class Expected:
+        @R.function
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"))):
+            with R.dataflow():
+                lv1: R.Tensor((3, 3), dtype="float32") = R.power(x, R.const(3, "float32"))
+                lv2: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = x, lv1
+                lv3: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv2
+                lv4: R.Tensor((3, 3), dtype="float32") = lv3[0]
+                lv4_1: R.Tensor((3, 3), dtype="float32") = R.power(lv4, R.const(3, "float32"))
+                gv: R.Tensor((), dtype="float32") = R.sum(lv4_1, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv1_cp: R.Tensor((3, 3), dtype="float32") = R.power(x, R.const(3, "float32"))
+                lv2_cp: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = x, lv1_cp
+                lv3_cp: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv2_cp
+                lv4_cp: R.Tensor((3, 3), dtype="float32") = lv3_cp[0]
+                lv4_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv: R.Tensor((3, 3), dtype="float32") = R.multiply(lv4_adjoint, R.const(3, "float32"))
+                lv1_1: R.Tensor((), dtype="float32") = R.subtract(R.const(3, "float32"), R.const(1, "float32"))
+                lv2_1: R.Tensor((3, 3), dtype="float32") = R.power(lv4_cp, lv1_1)
+                lv4_adjoint1: R.Tensor((3, 3), dtype="float32") = R.multiply(lv, lv2_1)
+                lv6: R.Tensor((3, 3), dtype="float32") = R.zeros(R.shape([3, 3]), dtype="float32")
+                lv3_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv4_adjoint1, lv6
+                lv2_adjoint: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv3_adjoint
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = lv2_adjoint[0]
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = lv2_adjoint[1]
+                lv7: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1_adjoint, R.const(3, "float32"))
+                lv8: R.Tensor((), dtype="float32") = R.subtract(R.const(3, "float32"), R.const(1, "float32"))
+                lv9: R.Tensor((3, 3), dtype="float32") = R.power(x, lv8)
+                lv12: R.Tensor((3, 3), dtype="float32") = R.multiply(lv7, lv9)
+                x_adjoint1: R.Tensor((3, 3), dtype="float32") = R.add(x_adjoint, lv12)
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint1
+                R.output(gv, x_adjoint_out)
+            return (gv, (x_adjoint_out,))
+
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
+            with R.dataflow():
+                x_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(x)
+                lv1: R.Tensor((3, 3), dtype="float32") = R.power(x_scp, R.const(3, "float32"))
+                lv2: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = x, lv1
+                lv3: R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32")) = lv2
+                lv4: R.Tensor((3, 3), dtype="float32") = lv3[0]
+                lv4_1: R.Tensor((3, 3), dtype="float32") = R.power(lv4, R.const(3, "float32"))
+                lv4_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv4_1)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv4_ecp, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
+    # fmt: on
+
+    After = relax.transform.Gradient("main")(Before)
+    assert_structural_equal(After, Expected)
+
+
+def test_tree():
+    """Comp. graph is a output-directed tree"""
+    # fmt: off
+    @I.ir_module
+    class Before:
+        @R.function
+        def main(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32"), z: R.Tensor((3, 3), "float32"), u: R.Tensor((3, 3), "float32"), v: R.Tensor((3, 3), "float32")):
+            with R.dataflow():
+                lv1 = x * y
+                lv1_scp = R.grad.start_checkpoint(lv1)
+                z_scp = R.grad.start_checkpoint(z)
+                lv2 = lv1_scp * z_scp
+                lv2_ecp = R.grad.end_checkpoint(lv2)
+                u_scp = R.grad.start_checkpoint(u)
+                v_scp = R.grad.start_checkpoint(v)
+                lv3 = u_scp * v_scp
+                lv3_ecp = R.grad.end_checkpoint(lv3)
+                lv4 = lv2_ecp * lv3_ecp
+                gv = R.sum(lv4)
+                R.output(gv)
+            return gv
+
+    @I.ir_module
+    class Expected1:
+        @R.function
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32"), u: R.Tensor((3, 3), dtype="float32"), v: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"), R.Tensor((3, 3), dtype="float32"))):
+            with R.dataflow():
+                lv1: R.Tensor((3, 3), dtype="float32") = R.multiply(x, y)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1, z)
+                lv3: R.Tensor((3, 3), dtype="float32") = R.multiply(u, v)
+                lv4: R.Tensor((3, 3), dtype="float32") = R.multiply(lv2, lv3)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv4, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv4_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv2_cp: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1, z)
+                lv3_cp: R.Tensor((3, 3), dtype="float32") = R.multiply(u, v)
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv4_adjoint, lv3_cp)
+                lv3_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv4_adjoint, lv2_cp)
+                u_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv3_adjoint, v)
+                v_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv3_adjoint, u)
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv2_adjoint, z)
+                z_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv2_adjoint, lv1)
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1_adjoint, y)
+                y_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1_adjoint, x)
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint
+                y_adjoint_out: R.Tensor((3, 3), dtype="float32") = y_adjoint
+                z_adjoint_out: R.Tensor((3, 3), dtype="float32") = z_adjoint
+                u_adjoint_out: R.Tensor((3, 3), dtype="float32") = u_adjoint
+                v_adjoint_out: R.Tensor((3, 3), dtype="float32") = v_adjoint
+                R.output(gv, x_adjoint_out, y_adjoint_out, z_adjoint_out, u_adjoint_out, v_adjoint_out)
+            return (gv, (x_adjoint_out, y_adjoint_out, z_adjoint_out, u_adjoint_out, v_adjoint_out))
+
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32"), u: R.Tensor((3, 3), dtype="float32"), v: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
+            with R.dataflow():
+                lv1 = x * y
+                lv1_scp = R.grad.start_checkpoint(lv1)
+                z_scp = R.grad.start_checkpoint(z)
+                lv2 = lv1_scp * z_scp
+                lv2_ecp = R.grad.end_checkpoint(lv2)
+                u_scp = R.grad.start_checkpoint(u)
+                v_scp = R.grad.start_checkpoint(v)
+                lv3 = u_scp * v_scp
+                lv3_ecp = R.grad.end_checkpoint(lv3)
+                lv4 = lv2_ecp * lv3_ecp
+                gv = R.sum(lv4)
+                R.output(gv)
+            return gv
+    # fmt: on
+
+    After1 = relax.transform.Gradient("main")(Before)
+    assert_structural_equal(After1, Expected1)
+
+    # fmt: off
+    @I.ir_module
+    class Expected2:
+        @R.function
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32"), u: R.Tensor((3, 3), dtype="float32"), v: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"))):
+            with R.dataflow():
+                lv1: R.Tensor((3, 3), dtype="float32") = R.multiply(x, y)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1, z)
+                lv3: R.Tensor((3, 3), dtype="float32") = R.multiply(u, v)
+                lv4: R.Tensor((3, 3), dtype="float32") = R.multiply(lv2, lv3)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv4, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv4_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv3_cp: R.Tensor((3, 3), dtype="float32") = R.multiply(u, v)
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv4_adjoint, lv3_cp)
+                z_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv2_adjoint, lv1)
+                z_adjoint_out: R.Tensor((3, 3), dtype="float32") = z_adjoint
+                R.output(gv, z_adjoint_out)
+            return (gv, (z_adjoint_out,))
+
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32"), y: R.Tensor((3, 3), dtype="float32"), z: R.Tensor((3, 3), dtype="float32"), u: R.Tensor((3, 3), dtype="float32"), v: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
+            with R.dataflow():
+                lv1 = x * y
+                lv1_scp = R.grad.start_checkpoint(lv1)
+                z_scp = R.grad.start_checkpoint(z)
+                lv2 = lv1_scp * z_scp
+                lv2_ecp = R.grad.end_checkpoint(lv2)
+                u_scp = R.grad.start_checkpoint(u)
+                v_scp = R.grad.start_checkpoint(v)
+                lv3 = u_scp * v_scp
+                lv3_ecp = R.grad.end_checkpoint(lv3)
+                lv4 = lv2_ecp * lv3_ecp
+                gv = R.sum(lv4)
+                R.output(gv)
+            return gv
+    # fmt: on
+
+    After2 = relax.transform.Gradient("main", require_grads=Before["main"].params[2])(Before)
+    assert_structural_equal(After2, Expected2)
+
+
+def test_dag():
+    """Comp. graph is a DAG with only one output. Here we only test the simple case: comp. graph
+    is a sequence of sub-graphs, and the checkpoints are the intersections of connected
+    subgraphs."""
+    # fmt: off
+    @I.ir_module
+    class Before:
+        @R.function
+        def main(x: R.Tensor((3, 3), "float32")):
+            with R.dataflow():
+                lv = R.grad.start_checkpoint(x)
+                lv1 = R.multiply(lv, R.const(2, "float32"))
+                lv2 = R.multiply(lv1, R.const(2, "float32"))
+                lv3 = R.grad.end_checkpoint(lv2)
+                lv4 = R.multiply(x, lv3)
+                lv5 = R.grad.start_checkpoint(lv4)
+                lv6 = R.multiply(lv5, R.const(2, "float32"))
+                lv7 = R.multiply(lv6, R.const(2, "float32"))
+                lv8 = R.grad.end_checkpoint(lv7)
+                lv9 = R.multiply(lv4, lv8)
+                lv10 = R.grad.start_checkpoint(lv9)
+                lv11 = R.multiply(lv10, R.const(2, "float32"))
+                lv12 = R.multiply(lv11, R.const(2, "float32"))
+                lv13 = R.grad.end_checkpoint(lv12)
+                lv14 = R.multiply(lv9, lv13)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv14, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
+
+    @I.ir_module
+    class Expected:
+        @R.function
+        def main_adjoint(x: R.Tensor((3, 3), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((3, 3), dtype="float32"))):
+            with R.dataflow():
+                lv1: R.Tensor((3, 3), dtype="float32") = R.multiply(x, R.const(2, "float32"))
+                lv2: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1, R.const(2, "float32"))
+                lv3: R.Tensor((3, 3), dtype="float32") = R.multiply(x, lv2)
+                lv4: R.Tensor((3, 3), dtype="float32") = R.multiply(lv3, R.const(2, "float32"))
+                lv5: R.Tensor((3, 3), dtype="float32") = R.multiply(lv4, R.const(2, "float32"))
+                lv6: R.Tensor((3, 3), dtype="float32") = R.multiply(lv3, lv5)
+                lv7: R.Tensor((3, 3), dtype="float32") = R.multiply(lv6, R.const(2, "float32"))
+                lv8: R.Tensor((3, 3), dtype="float32") = R.multiply(lv7, R.const(2, "float32"))
+                lv9: R.Tensor((3, 3), dtype="float32") = R.multiply(lv6, lv8)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv9, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv9_adjoint: R.Tensor((3, 3), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([3, 3]))
+                lv7_cp: R.Tensor((3, 3), dtype="float32") = R.multiply(lv6, R.const(2, "float32"))
+                lv8_cp: R.Tensor((3, 3), dtype="float32") = R.multiply(lv7_cp, R.const(2, "float32"))
+                lv6_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv9_adjoint, lv8_cp)
+                lv8_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv9_adjoint, lv6)
+                lv7_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv8_adjoint, R.const(2, "float32"))
+                lv1_1: R.Tensor((3, 3), dtype="float32") = R.multiply(lv7_adjoint, R.const(2, "float32"))
+                lv6_adjoint1: R.Tensor((3, 3), dtype="float32") = R.add(lv6_adjoint, lv1_1)
+                lv4_cp: R.Tensor((3, 3), dtype="float32") = R.multiply(lv3, R.const(2, "float32"))
+                lv5_cp: R.Tensor((3, 3), dtype="float32") = R.multiply(lv4_cp, R.const(2, "float32"))
+                lv3_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv6_adjoint1, lv5_cp)
+                lv5_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv6_adjoint1, lv3)
+                lv4_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv5_adjoint, R.const(2, "float32"))
+                lv4_1: R.Tensor((3, 3), dtype="float32") = R.multiply(lv4_adjoint, R.const(2, "float32"))
+                lv3_adjoint1: R.Tensor((3, 3), dtype="float32") = R.add(lv3_adjoint, lv4_1)
+                lv1_cp: R.Tensor((3, 3), dtype="float32") = R.multiply(x, R.const(2, "float32"))
+                lv2_cp: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1_cp, R.const(2, "float32"))
+                x_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv3_adjoint1, lv2_cp)
+                lv2_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv3_adjoint1, x)
+                lv1_adjoint: R.Tensor((3, 3), dtype="float32") = R.multiply(lv2_adjoint, R.const(2, "float32"))
+                lv7_1: R.Tensor((3, 3), dtype="float32") = R.multiply(lv1_adjoint, R.const(2, "float32"))
+                x_adjoint1: R.Tensor((3, 3), dtype="float32") = R.add(x_adjoint, lv7_1)
+                x_adjoint_out: R.Tensor((3, 3), dtype="float32") = x_adjoint1
+                R.output(gv, x_adjoint_out)
+            return (gv, (x_adjoint_out,))
+
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
+            with R.dataflow():
+                lv = R.grad.start_checkpoint(x)
+                lv1 = R.multiply(lv, R.const(2, "float32"))
+                lv2 = R.multiply(lv1, R.const(2, "float32"))
+                lv3 = R.grad.end_checkpoint(lv2)
+                lv4 = R.multiply(x, lv3)
+                lv5 = R.grad.start_checkpoint(lv4)
+                lv6 = R.multiply(lv5, R.const(2, "float32"))
+                lv7 = R.multiply(lv6, R.const(2, "float32"))
+                lv8 = R.grad.end_checkpoint(lv7)
+                lv9 = R.multiply(lv4, lv8)
+                lv10 = R.grad.start_checkpoint(lv9)
+                lv11 = R.multiply(lv10, R.const(2, "float32"))
+                lv12 = R.multiply(lv11, R.const(2, "float32"))
+                lv13 = R.grad.end_checkpoint(lv12)
+                lv14 = R.multiply(lv9, lv13)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv14, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
+    # fmt: on
+
+    After = relax.transform.Gradient("main")(Before)
+    assert_structural_equal(After, Expected)
+
+
+def test_checkpoint_api():
+    """Test on tvm.relax.testing.nn.checkpoint API"""
+
+    def func1(x):
+        return relax.op.power(x, relax.const(3, "float32"))
+
+    def func2(x):
+        y = relax.op.power(relax.op.power(x, relax.const(3, "float32")), relax.const(3, "float32"))
+        return relax.op.sum(y)
+
+    bb = BlockBuilder()
+    x = relax.Var("x", relax.TensorStructInfo((3, 3), "float32"))
+    with bb.function("main", [x]):
+        with bb.dataflow():
+            lv1 = bb.emit(checkpoint(func1, x))
+            lv2 = bb.emit(relax.op.power(lv1, relax.const(3, "float32")))
+            lv3 = bb.emit_output(checkpoint(func2, lv2))
+        bb.emit_func_output(lv3)
+
+    # fmt: off
+    @I.ir_module
+    class Expected:
+        @R.function
+        def main(x: R.Tensor((3, 3), "float32")):
+            with R.dataflow():
+                x_scp = R.grad.start_checkpoint(x)
+                lv1 = R.power(x_scp, R.const(3, "float32"))
+                lv1_ecp = R.grad.end_checkpoint(lv1)
+                lv2 = R.power(lv1_ecp, R.const(3, "float32"))
+                lv2_scp = R.grad.start_checkpoint(lv2)
+                lv3 = R.power(lv2_scp, R.const(3, "float32"))
+                lv4 = R.power(lv3, R.const(3, "float32"))
+                gv = R.sum(lv4)
+                gv_ecp = R.grad.end_checkpoint(gv)
+                R.output(gv_ecp)
+            return gv_ecp
+    # fmt: on
+
+    assert_structural_equal(bb.get(), Expected)
+
+
+def test_checkpoint_tree():
+    """Comp. graph is a output-directed tree"""
+
+    def func(x, y, z, w):
+        return x * y, z * w
+
+    bb = BlockBuilder()
+    x = relax.Var("x", relax.TensorStructInfo((3, 3), "float32"))
+    y = relax.Var("y", relax.TensorStructInfo((3, 3), "float32"))
+    z = relax.Var("z", relax.TensorStructInfo((3, 3), "float32"))
+    u = relax.Var("u", relax.TensorStructInfo((3, 3), "float32"))
+    v = relax.Var("v", relax.TensorStructInfo((3, 3), "float32"))
+    with bb.function("main", [x, y, z, u, v]):
+        with bb.dataflow():
+            lv1 = bb.emit(x * y)
+            cp = checkpoint(func, lv1, z, u, v)
+            lv2 = bb.emit(cp[0])
+            lv3 = bb.emit(cp[1])
+            lv4 = bb.emit(lv2 * lv3)
+            gv = bb.emit_output(relax.op.sum(lv4))
+        bb.emit_func_output(gv)
+
+    # fmt: off
+    @I.ir_module
+    class Expected:
+        @R.function
+        def main(x: R.Tensor((3, 3), "float32"), y: R.Tensor((3, 3), "float32"), z: R.Tensor((3, 3), "float32"), u: R.Tensor((3, 3), "float32"), v: R.Tensor((3, 3), "float32")):
+            with R.dataflow():
+                lv1 = x * y
+                lv1_scp = R.grad.start_checkpoint(lv1)
+                z_scp = R.grad.start_checkpoint(z)
+                lv2 = lv1_scp * z_scp
+                lv2_ecp = R.grad.end_checkpoint(lv2)
+                u_scp = R.grad.start_checkpoint(u)
+                v_scp = R.grad.start_checkpoint(v)
+                lv3 = u_scp * v_scp
+                lv3_ecp = R.grad.end_checkpoint(lv3)
+                lv4 = lv2_ecp * lv3_ecp
+                gv = R.sum(lv4)
+                R.output(gv)
+            return gv
+    # fmt: on
+
+    assert_structural_equal(bb.get(), Expected)
+
+
+def test_checkpoint_dag():
+    """Comp. graph is a DAG with only one output. Here we only test the simple case: comp. graph
+    is a sequence of sub-graphs, and the checkpoints are the intersections of connected
+    subgraphs."""
+
+    def func(x):
+        return x * relax.const(2, "float32") * relax.const(2, "float32")
+
+    bb = BlockBuilder()
+    x = relax.Var("x", relax.TensorStructInfo((3, 3), "float32"))
+    with bb.function("main", [x]):
+        with bb.dataflow():
+            lv1 = bb.emit(checkpoint(func, x))
+            lv2 = bb.emit(x * lv1)
+            lv3 = bb.emit(checkpoint(func, lv2))
+            lv4 = bb.emit(lv2 * lv3)
+            lv5 = bb.emit(checkpoint(func, lv4))
+            lv6 = bb.emit(lv4 * lv5)
+            gv = bb.emit_output(relax.op.sum(lv6))
+        bb.emit_func_output(gv)
+
+    # fmt: off
+    @I.ir_module
+    class Expected:
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((), dtype="float32"):
+            with R.dataflow():
+                lv = R.grad.start_checkpoint(x)
+                lv1 = R.multiply(lv, R.const(2, "float32"))
+                lv2 = R.multiply(lv1, R.const(2, "float32"))
+                lv3 = R.grad.end_checkpoint(lv2)
+                lv4 = R.multiply(x, lv3)
+                lv5 = R.grad.start_checkpoint(lv4)
+                lv6 = R.multiply(lv5, R.const(2, "float32"))
+                lv7 = R.multiply(lv6, R.const(2, "float32"))
+                lv8 = R.grad.end_checkpoint(lv7)
+                lv9 = R.multiply(lv4, lv8)
+                lv10 = R.grad.start_checkpoint(lv9)
+                lv11 = R.multiply(lv10, R.const(2, "float32"))
+                lv12 = R.multiply(lv11, R.const(2, "float32"))
+                lv13 = R.grad.end_checkpoint(lv12)
+                lv14 = R.multiply(lv9, lv13)
+                gv: R.Tensor((), dtype="float32") = R.sum(lv14, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
+    # fmt: on
+
+    assert_structural_equal(bb.get(), Expected)
+
+
+def test_checkpoint_sequential():
+    def func(x):
+        return x + x
+
+    bb = BlockBuilder()
+    x = relax.Var("x", relax.TensorStructInfo((3, 3), "float32"))
+    with bb.function("main", [x]):
+        with bb.dataflow():
+            lv1 = emit_checkpoint_sequential([func] * 5, 2, x)
+            lv2 = emit_checkpoint_sequential([func] * 4, 2, lv1)
+            gv = bb.emit_output(lv2)
+        bb.emit_func_output(gv)
+
+    # fmt: off
+    @I.ir_module
+    class Expected:
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((3, 3), dtype="float32"):
+            with R.dataflow():
+                x_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(x)
+                lv: R.Tensor((3, 3), dtype="float32") = R.add(x_scp, x_scp)
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(lv, lv)
+                lv1_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv1)
+                lv1_ecp_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(lv1_ecp)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.add(lv1_ecp_scp, lv1_ecp_scp)
+                lv3: R.Tensor((3, 3), dtype="float32") = R.add(lv2, lv2)
+                lv3_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv3)
+                lv4: R.Tensor((3, 3), dtype="float32") = R.add(lv3_ecp, lv3_ecp)
+                lv4_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(lv4)
+                lv5: R.Tensor((3, 3), dtype="float32") = R.add(lv4_scp, lv4_scp)
+                lv6: R.Tensor((3, 3), dtype="float32") = R.add(lv5, lv5)
+                lv6_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv6)
+                lv7: R.Tensor((3, 3), dtype="float32") = R.add(lv6_ecp, lv6_ecp)
+                lv8: R.Tensor((3, 3), dtype="float32") = R.add(lv7, lv7)
+                gv: R.Tensor((3, 3), dtype="float32") = lv8
+                R.output(gv)
+            return gv
+    # fmt: on
+
+    assert_structural_equal(bb.get(), Expected)
+
+
+def test_checkpoint_sequential_checkpoint_last():
+    def func(x):
+        return x + x
+
+    bb = BlockBuilder()
+    x = relax.Var("x", relax.TensorStructInfo((3, 3), "float32"))
+    with bb.function("main", [x]):
+        with bb.dataflow():
+            lv1 = emit_checkpoint_sequential([func] * 5, 2, x, checkpoint_last=True)
+            lv2 = emit_checkpoint_sequential([func] * 4, 2, lv1, checkpoint_last=True)
+            gv = bb.emit_output(lv2)
+        bb.emit_func_output(gv)
+
+    # fmt: off
+    @I.ir_module
+    class Expected:
+        @R.function
+        def main(x: R.Tensor((3, 3), dtype="float32")) -> R.Tensor((3, 3), dtype="float32"):
+            with R.dataflow():
+                x_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(x)
+                lv: R.Tensor((3, 3), dtype="float32") = R.add(x_scp, x_scp)
+                lv1: R.Tensor((3, 3), dtype="float32") = R.add(lv, lv)
+                lv1_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv1)
+                lv1_ecp_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(lv1_ecp)
+                lv2: R.Tensor((3, 3), dtype="float32") = R.add(lv1_ecp_scp, lv1_ecp_scp)
+                lv3: R.Tensor((3, 3), dtype="float32") = R.add(lv2, lv2)
+                lv3_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv3)
+                lv3_ecp_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(lv3_ecp)
+                lv4: R.Tensor((3, 3), dtype="float32") = R.add(lv3_ecp_scp, lv3_ecp_scp)
+                lv4_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv4)
+                lv4_ecp_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(lv4_ecp)
+                lv5: R.Tensor((3, 3), dtype="float32") = R.add(lv4_ecp_scp, lv4_ecp_scp)
+                lv6: R.Tensor((3, 3), dtype="float32") = R.add(lv5, lv5)
+                lv6_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv6)
+                lv6_ecp_scp: R.Tensor((3, 3), dtype="float32") = R.grad.start_checkpoint(lv6_ecp)
+                lv7: R.Tensor((3, 3), dtype="float32") = R.add(lv6_ecp_scp, lv6_ecp_scp)
+                lv8: R.Tensor((3, 3), dtype="float32") = R.add(lv7, lv7)
+                lv8_ecp: R.Tensor((3, 3), dtype="float32") = R.grad.end_checkpoint(lv8)
+                gv: R.Tensor((3, 3), dtype="float32") = lv8_ecp
+                R.output(gv)
+            return gv
+    # fmt: on
+
+    assert_structural_equal(bb.get(), Expected)
+
+
+if __name__ == "__main__":
+    tvm.testing.main()
diff --git a/tests/python/relax/test_training_register_te_gradient.py b/tests/python/relax/test_transform_gradient_te_register.py
similarity index 63%
rename from tests/python/relax/test_training_register_te_gradient.py
rename to tests/python/relax/test_transform_gradient_te_register.py
index 9803e4fab3..b6b785fe3c 100644
--- a/tests/python/relax/test_training_register_te_gradient.py
+++ b/tests/python/relax/test_transform_gradient_te_register.py
@@ -14,12 +14,12 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-"""Unit tests for relax training utils."""
+"""Unit tests for registering tir gradient functions in the gradient pass."""
 import pytest
 
 import tvm
 import tvm.testing
-from tvm import relax
+from tvm import relax, tir
 from tvm.ir.base import assert_structural_equal
 from tvm.script.parser import relax as R, tir as T, ir as I
 
@@ -91,21 +91,23 @@ def get_expected_1():
         def main_adjoint(a: R.Tensor((5, 5), dtype="float32"), b: R.Tensor((5, 5), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((5, 5), dtype="float32"), R.Tensor((5, 5), dtype="float32"))):
             cls = Expected
             with R.dataflow():
-                lv = R.call_tir(cls.f_mul, (a, b), out_sinfo=R.Tensor((5, 5), dtype="float32"), te_grad_name="f_mul_grad")
+                lv = R.call_tir(cls.f_mul, (a, b), out_sinfo=R.Tensor((5, 5), dtype="float32"))
                 gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
                 gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
                 lv_adjoint: R.Tensor((5, 5), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([5, 5]))
                 lv_1 = R.call_tir(cls.f_mul_grad, (lv_adjoint, a, b), out_sinfo=[R.Tensor((5, 5), dtype="float32"), R.Tensor((5, 5), dtype="float32")])
                 a_adjoint: R.Tensor((5, 5), dtype="float32") = lv_1[0]
                 b_adjoint: R.Tensor((5, 5), dtype="float32") = lv_1[1]
-                R.output(gv, a_adjoint, b_adjoint)
-            return (gv, (a_adjoint, b_adjoint))
+                a_adjoint_out: R.Tensor((5, 5), dtype="float32") = a_adjoint
+                b_adjoint_out: R.Tensor((5, 5), dtype="float32") = b_adjoint
+                R.output(gv, a_adjoint_out, b_adjoint_out)
+            return (gv, (a_adjoint_out, b_adjoint_out))
 
         @R.function
         def main(a: R.Tensor((5, 5), dtype="float32"), b: R.Tensor((5, 5), dtype="float32")) -> R.Tensor((), dtype="float32"):
             cls = Expected
             with R.dataflow():
-                lv = R.call_tir(cls.f_mul, (a, b), out_sinfo=R.Tensor((5, 5), dtype="float32"), te_grad_name="f_mul_grad")
+                lv = R.call_tir_with_grad(cls.f_mul, (a, b), out_sinfo=R.Tensor((5, 5), dtype="float32"), te_grad_name="f_mul_grad")
                 gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
                 R.output(gv)
             return gv
@@ -113,7 +115,6 @@ def get_expected_1():
     return Expected
 
 
-@pytest.mark.skip("gradient will be refactored in later pull requests")
 def test_emit_te(register_te_grads):
     # Build the target module using emit_te
     def f_mul(src1, src2):
@@ -128,7 +129,11 @@ def test_emit_te(register_te_grads):
     bb = relax.BlockBuilder()
     with bb.function("main", [a, b]):
         with bb.dataflow():
-            d = bb.emit_te(f_mul, a, b, primfunc_name_hint="f_mul", te_grad_name="f_mul_grad")
+            d = bb.emit(
+                bb.call_te_with_grad(
+                    f_mul, a, b, primfunc_name_hint="f_mul", te_grad_name="f_mul_grad"
+                )
+            )
             out = bb.emit_output(R.sum(d))
         bb.emit_func_output(out)
 
@@ -137,7 +142,6 @@ def test_emit_te(register_te_grads):
     assert_structural_equal(After, get_expected_1())
 
 
-@pytest.mark.skip("gradient will be refactored in later pull requests")
 def test_call_tir(register_te_grads):
     # fmt: off
     @I.ir_module
@@ -157,7 +161,7 @@ def test_call_tir(register_te_grads):
         def main(a: R.Tensor((5, 5), dtype="float32"), b: R.Tensor((5, 5), dtype="float32")) -> R.Tensor((), dtype="float32"):
             cls = Before
             with R.dataflow():
-                lv = R.call_tir(cls.f_mul, (a, b), out_sinfo=R.Tensor((5, 5), dtype="float32"), te_grad_name="f_mul_grad")
+                lv = R.call_tir_with_grad(cls.f_mul, (a, b), out_sinfo=R.Tensor((5, 5), dtype="float32"), te_grad_name="f_mul_grad")
                 gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
                 R.output(gv)
             return gv
@@ -197,20 +201,21 @@ def get_expected_2():
         def main_adjoint(a: R.Tensor((5, 5), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor((5, 5), dtype="float32"))):
             cls = Expected
             with R.dataflow():
-                lv = R.call_tir(cls.f_mul, (a,), out_sinfo=R.Tensor((5, 5), dtype="float32"), te_grad_name="f_mulk_grad", te_grad_kwargs={"k": T.float32(2)})
+                lv = R.call_tir(cls.f_mul, (a,), out_sinfo=R.Tensor((5, 5), dtype="float32"))
                 gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
                 gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
                 lv_adjoint: R.Tensor((5, 5), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([5, 5]))
                 lv_1 = R.call_tir(cls.f_mulk_grad, (lv_adjoint, a), out_sinfo=R.Tensor((5, 5), dtype="float32"))
                 a_adjoint: R.Tensor((5, 5), dtype="float32") = lv_1
-                R.output(gv, a_adjoint)
-            return (gv, (a_adjoint,))
+                a_adjoint_out: R.Tensor((5, 5), dtype="float32") = a_adjoint
+                R.output(gv, a_adjoint_out)
+            return (gv, (a_adjoint_out,))
 
         @R.function
         def main(a: R.Tensor((5, 5), dtype="float32")) -> R.Tensor((), dtype="float32"):
             cls = Expected
             with R.dataflow():
-                lv = R.call_tir(cls.f_mul, (a,), out_sinfo=R.Tensor((5, 5), dtype="float32"), te_grad_name="f_mulk_grad", te_grad_kwargs={"k": T.float32(2)})
+                lv = R.call_tir_with_grad(cls.f_mul, (a,), out_sinfo=R.Tensor((5, 5), dtype="float32"), te_grad_name="f_mulk_grad", te_grad_kwargs={"k": T.float32(2)})
                 gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
                 R.output(gv)
             return gv
@@ -218,7 +223,6 @@ def get_expected_2():
     return Expected
 
 
-@pytest.mark.skip("gradient will be refactored in later pull requests")
 def test_emit_te_kwargs(register_te_grads):
     # Build the target module using emit_te
     def f_mul2(src):
@@ -229,24 +233,24 @@ def test_emit_te_kwargs(register_te_grads):
     bb = relax.BlockBuilder()
     with bb.function("main", [a]):
         with bb.dataflow():
-            d = bb.emit_te(
-                f_mul2,
-                a,
-                primfunc_name_hint="f_mul",
-                te_grad_name="f_mulk_grad",
-                te_grad_kwargs={"k": T.float32(2)},
+            d = bb.emit(
+                bb.call_te_with_grad(
+                    f_mul2,
+                    a,
+                    primfunc_name_hint="f_mul",
+                    te_grad_name="f_mulk_grad",
+                    te_grad_kwargs={"k": T.float32(2)},
+                )
             )
             out = bb.emit_output(R.sum(d))
         bb.emit_func_output(out)
 
     Before = bb.get()
     After = Gradient("main")(Before)
-    After.show(None, False)
 
     assert_structural_equal(After, get_expected_2())
 
 
-@pytest.mark.skip("gradient will be refactored in later pull requests")
 def test_call_tir_kwargs(register_te_grads):
     # fmt: off
     @I.ir_module
@@ -266,7 +270,7 @@ def test_call_tir_kwargs(register_te_grads):
         def main(a: R.Tensor((5, 5), dtype="float32")) -> R.Tensor((), dtype="float32"):
             cls = Before
             with R.dataflow():
-                lv = R.call_tir(cls.f_mul, (a,), out_sinfo=R.Tensor((5, 5), dtype="float32"), te_grad_name="f_mulk_grad", te_grad_kwargs={"k": T.float32(2)})
+                lv = R.call_tir_with_grad(cls.f_mul, (a,), out_sinfo=R.Tensor((5, 5), dtype="float32"), te_grad_name="f_mulk_grad", te_grad_kwargs={"k": T.float32(2)})
                 gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
                 R.output(gv)
             return gv
@@ -276,5 +280,105 @@ def test_call_tir_kwargs(register_te_grads):
     assert_structural_equal(After, get_expected_2())
 
 
+def get_expected_3():
+    # fmt: off
+    @I.ir_module
+    class Expected:
+        @T.prim_func
+        def f_mul(var_A: T.handle, var_B: T.handle, var_f_mul: T.handle):
+            T.func_attr({"tir.noalias": T.bool(True)})
+            n = T.int64()
+            A = T.match_buffer(var_A, (n, n))
+            B = T.match_buffer(var_B, (n, n))
+            f_mul_1 = T.match_buffer(var_f_mul, (n, n))
+            # with T.block("root"):
+            for i0, i1 in T.grid(n, n):
+                with T.block("f_mul"):
+                    v_i0, v_i1 = T.axis.remap("SS", [i0, i1])
+                    T.reads(A[v_i0, v_i1], B[v_i0, v_i1])
+                    T.writes(f_mul_1[v_i0, v_i1])
+                    f_mul_1[v_i0, v_i1] = A[v_i0, v_i1] * B[v_i0, v_i1]
+
+        @T.prim_func
+        def f_mul_grad(var_A: T.handle, var_B: T.handle, var_C: T.handle, var_f_mul_grad_1: T.handle, var_f_mul_grad_2: T.handle):
+            T.func_attr({"tir.noalias": T.bool(True)})
+            n = T.int64()
+            A = T.match_buffer(var_A, (n, n))
+            B = T.match_buffer(var_B, (n, n))
+            C = T.match_buffer(var_C, (n, n))
+            f_mul_grad_1 = T.match_buffer(var_f_mul_grad_1, (n, n))
+            f_mul_grad_2 = T.match_buffer(var_f_mul_grad_2, (n, n))
+            # with T.block("root"):
+            for i0, i1 in T.grid(n, n):
+                with T.block("f_mul_grad_1"):
+                    v_i0, v_i1 = T.axis.remap("SS", [i0, i1])
+                    T.reads(C[v_i0, v_i1], A[v_i0, v_i1])
+                    T.writes(f_mul_grad_1[v_i0, v_i1])
+                    f_mul_grad_1[v_i0, v_i1] = C[v_i0, v_i1] * A[v_i0, v_i1]
+            for i0, i1 in T.grid(n, n):
+                with T.block("f_mul_grad_2"):
+                    v_i0, v_i1 = T.axis.remap("SS", [i0, i1])
+                    T.reads(B[v_i0, v_i1], A[v_i0, v_i1])
+                    T.writes(f_mul_grad_2[v_i0, v_i1])
+                    f_mul_grad_2[v_i0, v_i1] = B[v_i0, v_i1] * A[v_i0, v_i1]
+
+        @R.function
+        def main_adjoint(a: R.Tensor(("n", "n"), dtype="float32"), b: R.Tensor(("n", "n"), dtype="float32")) -> R.Tuple(R.Tensor((), dtype="float32"), R.Tuple(R.Tensor(("n", "n"), dtype="float32"), R.Tensor(("n", "n"), dtype="float32"))):
+            n = T.int64()
+            cls = Expected
+            with R.dataflow():
+                lv = R.call_tir(cls.f_mul, (a, b), out_sinfo=R.Tensor((n, n), dtype="float32"))
+                gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
+                gv_adjoint: R.Tensor((), dtype="float32") = R.ones(R.shape([]), dtype="float32")
+                lv_adjoint: R.Tensor((n, n), dtype="float32") = R.broadcast_to(gv_adjoint, R.shape([n, n]))
+                lv_1 = R.call_tir(cls.f_mul_grad, (lv_adjoint, a, b), out_sinfo=[R.Tensor((n, n), dtype="float32"), R.Tensor((n, n), dtype="float32")])
+                a_adjoint: R.Tensor((n, n), dtype="float32") = lv_1[0]
+                b_adjoint: R.Tensor((n, n), dtype="float32") = lv_1[1]
+                a_adjoint_out: R.Tensor((n, n), dtype="float32") = a_adjoint
+                b_adjoint_out: R.Tensor((n, n), dtype="float32") = b_adjoint
+                R.output(gv, a_adjoint_out, b_adjoint_out)
+            return (gv, (a_adjoint_out, b_adjoint_out))
+
+        @R.function
+        def main(a: R.Tensor(("n", "n"), dtype="float32"), b: R.Tensor(("n", "n"), dtype="float32")) -> R.Tensor((), dtype="float32"):
+            n = T.int64()
+            cls = Expected
+            with R.dataflow():
+                lv = R.call_tir_with_grad(cls.f_mul, (a, b), out_sinfo=R.Tensor((n, n), dtype="float32"), te_grad_name="f_mul_grad")
+                gv: R.Tensor((), dtype="float32") = R.sum(lv, axis=None, keepdims=False)
+                R.output(gv)
+            return gv
+    # fmt: on
+    return Expected
+
+
+def test_tir_var(register_te_grads):
+    def f_mul(src1, src2):
+        def mul(*idx):
+            return src1[idx] * src2[idx]
+
+        return tvm.te.compute(src1.shape, mul, name="f_mul")
+
+    n = tir.Var("n", "int64")
+    a = relax.Var("a", relax.TensorStructInfo([n, n], "float32"))
+    b = relax.Var("b", relax.TensorStructInfo([n, n], "float32"))
+
+    bb = relax.BlockBuilder()
+    with bb.function("main", [a, b]):
+        with bb.dataflow():
+            d = bb.emit(
+                bb.call_te_with_grad(
+                    f_mul, a, b, primfunc_name_hint="f_mul", te_grad_name="f_mul_grad"
+                )
+            )
+            out = bb.emit_output(R.sum(d))
+        bb.emit_func_output(out)
+
+    Before = bb.get()
+    After = Gradient("main")(Before)
+    assert_structural_equal(After, get_expected_3())
+    assert relax.analysis.well_formed(After)
+
+
 if __name__ == "__main__":
     tvm.testing.main()