# 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.

"""
.. _tutorial-bring-your-own-codegen:

Bring Your Own Codegen
======================

TVM's Bring Your Own Codegen (BYOC) framework lets you offload parts of a model
to a custom backend -- a hardware accelerator, an inference library, or your own
kernels -- while TVM compiles the rest.  This tutorial has two parts:

- **How BYOC works** -- we teach the flow with a bundled, hardware-free *example
  NPU* backend and then drive the **same flow** on a real production backend,
  NVIDIA TensorRT.  Both run a small, hand-written model so every step is
  visible; the only thing that changes between them is the backend, and that
  contrast is the lesson.
- **Deploying a real model** -- we then put it to work, taking an actual PyTorch
  ``nn.Module`` from export through TensorRT and running it on the GPU.

The example NPU is a teaching stub: its runtime logs the dispatch decisions an
NPU would make (memory tier, execution engine, fusion) but performs no real
computation, so its output buffers are left uninitialized.  We therefore check
*shapes*, not values, in the NPU sections -- its job is to make every BYOC step
visible with nothing hidden.  TensorRT then runs the identical flow for real, so
we cross-check its result against a reference.

**Prerequisites**: the example NPU sections need TVM built with
``USE_EXAMPLE_NPU_CODEGEN=ON`` and ``USE_EXAMPLE_NPU_RUNTIME=ON``; the TensorRT
sections need ``USE_TENSORRT_CODEGEN=ON``, ``USE_TENSORRT_RUNTIME=ON`` and
``USE_CUDA=ON`` plus a CUDA GPU and a matching TensorRT install (from NVIDIA's
``pip install tensorrt`` packages or the TensorRT archive); the final deployment
section also needs PyTorch.  Each section degrades gracefully when its backend is
unavailable.
"""

######################################################################
# Overview of the BYOC flow
# -------------------------
#
# BYOC plugs a custom backend into TVM's compilation pipeline in four steps:
#
# 1. **Register patterns** - describe which sequences of Relax ops the backend
#    can handle.
# 2. **Partition the graph** - group matched ops into composite functions.
# 3. **Run codegen** - lower each composite to the backend's representation
#    (a JSON graph for both backends in this tutorial).
# 4. **Execute** - the runtime dispatches each composite to the backend.
#
# Steps 1 and 2 are pure Python and run anywhere; steps 3 and 4 need the
# backend's codegen and runtime compiled into TVM, which is why the
# build-and-run cells below are guarded.

######################################################################
# Step 1: Import the backends to register their patterns
# ------------------------------------------------------
#
# Importing a backend module registers its patterns with TVM's global registry.
# Pattern registration is independent of the C++ build -- only codegen and the
# runtime require the backend to be compiled in -- so we probe each backend and
# guard the build-and-run cells accordingly.

import os
import tempfile

import numpy as np

import tvm
import tvm.relax.backend.contrib.example_npu
from tvm import relax
from tvm.relax.backend.contrib.tensorrt import partition_for_tensorrt
from tvm.relax.backend.pattern_registry import get_patterns_with_prefix
from tvm.relax.transform import FuseOpsByPattern, MergeCompositeFunctions, RunCodegen
from tvm.script import relax as R

has_example_npu_codegen = tvm.get_global_func("relax.ext.example_npu", True)
has_example_npu_runtime = tvm.get_global_func("runtime.ExampleNPUJSONRuntimeCreate", True)
has_example_npu = has_example_npu_codegen and has_example_npu_runtime

has_tensorrt_codegen = tvm.get_global_func("relax.ext.tensorrt", True) is not None
_is_trt_runtime_enabled = tvm.get_global_func("relax.is_tensorrt_runtime_enabled", True)
has_tensorrt = (
    has_tensorrt_codegen and _is_trt_runtime_enabled is not None and _is_trt_runtime_enabled()
)
has_cuda = tvm.cuda(0).exist

######################################################################
# Step 2: Define the model
# ------------------------
#
# A single convolution followed by a ReLU.  This one model is used for both
# backends.


@tvm.script.ir_module
class ConvReLU:
    @R.function
    def main(
        data: R.Tensor((1, 3, 32, 32), "float32"),
        weight: R.Tensor((16, 3, 3, 3), "float32"),
    ) -> R.Tensor((1, 16, 30, 30), "float32"):
        with R.dataflow():
            conv = relax.op.nn.conv2d(data, weight)
            out = relax.op.nn.relu(conv)
            R.output(out)
        return out


######################################################################
# Step 3: Partition for the example NPU
# -------------------------------------
#
# ``FuseOpsByPattern`` groups ops matching a registered pattern into composite
# functions; ``MergeCompositeFunctions`` then consolidates adjacent composites
# bound for the same backend into a single external call.  Two flags steer
# partitioning:
#
# - ``bind_constants=False`` keeps weights as function arguments, so the host
#   stays in charge of the parameters.  (TensorRT below makes the opposite
#   choice: it binds weights as constants because it bakes them into its engine.)
# - ``annotate_codegen=True`` wraps each matched composite in a function tagged
#   with the backend name -- the tag ``RunCodegen`` routes on.  (The follow-up
#   ``MergeCompositeFunctions`` also attaches this tag when it groups composites,
#   which is why ``partition_for_tensorrt`` below can leave the flag off.)
#
# The example NPU registers a fused ``conv2d + relu`` pattern with higher
# priority than the standalone ``conv2d`` pattern, so the two ops collapse into a
# single ``example_npu.conv2d_relu_fused`` composite -- look for it in the
# printed module.

npu_patterns = get_patterns_with_prefix("example_npu")
npu_mod = FuseOpsByPattern(npu_patterns, bind_constants=False, annotate_codegen=True)(ConvReLU)
npu_mod = MergeCompositeFunctions()(npu_mod)
print("After partitioning for the example NPU:")
print(npu_mod)

######################################################################
# Step 4: Codegen, build and run on the example NPU
# -------------------------------------------------
#
# ``RunCodegen`` invokes each annotated composite's backend codegen, replacing it
# with the backend runtime module (here, the NPU's JSON graph); ``relax.build``
# then compiles the remaining host-side program and links everything.  Because
# the runtime is a stub that computes nothing, we assert on the output *shape*
# only -- the values are uninitialized.

np.random.seed(0)
data_np = np.random.randn(1, 3, 32, 32).astype("float32")
weight_np = np.random.randn(16, 3, 3, 3).astype("float32")

if has_example_npu:
    npu_mod = RunCodegen()(npu_mod)

    with tvm.transform.PassContext(opt_level=3):
        npu_exec = relax.build(npu_mod, tvm.target.Target("llvm"))

    npu_vm = relax.VirtualMachine(npu_exec, tvm.cpu())
    npu_out = npu_vm["main"](
        tvm.runtime.tensor(data_np, tvm.cpu()), tvm.runtime.tensor(weight_np, tvm.cpu())
    )
    assert npu_out.numpy().shape == (1, 16, 30, 30)
    print("Example NPU run completed. Output shape:", npu_out.numpy().shape)
else:
    print("Example NPU backend unavailable; skipping its build and run.")

######################################################################
# The same flow on a real backend: TensorRT
# -----------------------------------------
#
# Steps 1-4 above are the whole mechanism.  Aiming them at a real backend
# changes very little, so rather than repeat the walkthrough, here is only what
# differs for NVIDIA TensorRT:
#
# - **Partition in one call.** ``partition_for_tensorrt`` bundles the
#   ``FuseOpsByPattern`` + ``MergeCompositeFunctions`` you ran by hand, using
#   TensorRT's own pattern table.
# - **Weights become constants** (``bind_constants=True``): TensorRT bakes them
#   into the engine it builds, so bind the parameters before partitioning.
# - **Real values.** TensorRT actually computes, so we build for CUDA, run on
#   the GPU, and cross-check against a plain CPU build -- not just the shape.
#
# The build-and-run cells below execute only when TensorRT and CUDA are
# available. In CPU-only documentation builds, they produce no output.

trt_mod = relax.transform.BindParams("main", {"weight": weight_np})(ConvReLU)
trt_mod = partition_for_tensorrt(trt_mod)
print("After partition_for_tensorrt:")
print(trt_mod)

######################################################################
# Build for CUDA, run on the GPU, and compare against the CPU reference.

if has_tensorrt and has_cuda:
    dev = tvm.cuda(0)
    with tvm.transform.PassContext(opt_level=3):
        trt_exec = relax.build(RunCodegen()(trt_mod), "cuda")
    trt_out = relax.VirtualMachine(trt_exec, dev)["main"](tvm.runtime.tensor(data_np, dev)).numpy()

    cpu_mod = relax.transform.LegalizeOps()(
        relax.transform.BindParams("main", {"weight": weight_np})(ConvReLU)
    )
    cpu_exec = relax.build(cpu_mod, "llvm")
    cpu_out = relax.VirtualMachine(cpu_exec, tvm.cpu())["main"](
        tvm.runtime.tensor(data_np, tvm.cpu())
    ).numpy()

    np.testing.assert_allclose(trt_out, cpu_out, rtol=1e-2, atol=1e-2)
    print("TensorRT output shape:", trt_out.shape, "- matches the CPU reference.")

######################################################################
# A real backend also exposes knobs the stub does not.  Setting ``use_fp16``
# through the ``relax.ext.tensorrt.options`` config lets TensorRT pick FP16
# kernels, trading a little accuracy for speed; nothing else about the flow
# changes.  (Other options are environment-driven: ``TVM_TENSORRT_USE_INT8``
# enables INT8 with calibration, ``TVM_TENSORRT_MAX_WORKSPACE_SIZE`` caps the
# build workspace, and ``TVM_TENSORRT_CACHE_DIR`` caches built engines to disk
# for reuse across runs.)

if has_tensorrt and has_cuda:
    fp16_mod = partition_for_tensorrt(
        relax.transform.BindParams("main", {"weight": weight_np})(ConvReLU)
    )
    with tvm.transform.PassContext(
        opt_level=3, config={"relax.ext.tensorrt.options": {"use_fp16": True}}
    ):
        fp16_exec = relax.build(RunCodegen()(fp16_mod), "cuda")
    fp16_out = relax.VirtualMachine(fp16_exec, tvm.cuda(0))["main"](
        tvm.runtime.tensor(data_np, tvm.cuda(0))
    ).numpy()

    np.testing.assert_allclose(fp16_out, cpu_out, rtol=5e-2, atol=5e-2)
    print("TensorRT FP16 output shape:", fp16_out.shape, "- matches within FP16 tolerance.")

######################################################################
# Example NPU vs TensorRT at a glance
# -----------------------------------
#
# The same four-step flow, two backends:
#
# =========  ==============================  ==================================
# Aspect     Example NPU (teaching stub)     TensorRT (real backend)
# =========  ==============================  ==================================
# Runtime    logs decisions, no compute      builds and runs an nvinfer engine
# Output     uninitialized (check shape)     real values (cross-checked vs CPU)
# Weights    ``bind_constants=False``        ``bind_constants=True`` (baked in)
# Partition  two passes, by hand             ``partition_for_tensorrt`` one call
# =========  ==============================  ==================================

######################################################################
# Deploying a PyTorch model with TensorRT
# ---------------------------------------
#
# Everything above used a hand-written ``IRModule`` so each op was visible.  In
# practice you start from a trained model.  This final section runs the *same*
# ``partition_for_tensorrt`` flow on a real PyTorch ``nn.Module``, end to end:
# export it, import it into Relax with the PyTorch frontend (the weights come in
# as constants -- exactly what TensorRT bakes into its engine), partition, build
# for CUDA, and check the GPU result against PyTorch's own output.  Beyond the
# frontend import, the only difference is that the imported program returns its
# outputs as a tuple, so we index ``[0]`` for the single result tensor; the
# partition-build-run flow is otherwise unchanged.
#
# This section additionally requires PyTorch.

try:
    import torch
    from torch import nn

    has_torch = True
except ImportError:
    has_torch = False

if has_torch and has_tensorrt and has_cuda:
    from tvm.relax.frontend.torch import from_exported_program

    class SmallConvNet(nn.Module):
        def __init__(self):
            super().__init__()
            self.conv1 = nn.Conv2d(3, 8, 3)
            self.conv2 = nn.Conv2d(8, 16, 3)
            self.pool = nn.MaxPool2d(2)

        def forward(self, x):
            x = torch.relu(self.conv1(x))
            x = self.pool(x)
            x = torch.relu(self.conv2(x))
            return x

    torch_model = SmallConvNet().eval()
    example_input = torch.randn(1, 3, 32, 32)
    with torch.no_grad():
        torch_ref = torch_model(example_input).numpy()
        exported = torch.export.export(torch_model, (example_input,))

    torch_mod = from_exported_program(exported)
    torch_mod = partition_for_tensorrt(torch_mod)
    print("After importing and partitioning the PyTorch model:")
    print(torch_mod)

    torch_dev = tvm.cuda(0)
    with tvm.transform.PassContext(opt_level=3):
        torch_exec = relax.build(RunCodegen()(torch_mod), "cuda")
    deployed = relax.VirtualMachine(torch_exec, torch_dev)["main"](
        tvm.runtime.tensor(example_input.numpy(), torch_dev)
    )[0].numpy()

    np.testing.assert_allclose(deployed, torch_ref, rtol=1e-2, atol=1e-2)
    print("Deployed PyTorch model on TensorRT; output", deployed.shape, "matches PyTorch.")

######################################################################
# Real deployment builds once and reuses the artifact.  Export the compiled
# module to a shared library, then load and run it later -- in a fresh process,
# with no PyTorch and no rebuild needed.

if has_torch and has_tensorrt and has_cuda:
    with tempfile.TemporaryDirectory() as tmpdir:
        lib_path = os.path.join(tmpdir, "deployed_trt.so")
        torch_exec.export_library(lib_path)
        loaded = tvm.runtime.load_module(lib_path)
        reran = relax.VirtualMachine(loaded, torch_dev)["main"](
            tvm.runtime.tensor(example_input.numpy(), torch_dev)
        )[0].numpy()
        np.testing.assert_allclose(reran, torch_ref, rtol=1e-2, atol=1e-2)
        print("Reloaded the exported library and reran; output", reran.shape, "still matches.")

######################################################################
# Notes for real deployments
# --------------------------
#
# - **Operator coverage and fallback.** TensorRT offloads only the ops in its
#   pattern table (see ``python/tvm/relax/backend/contrib/tensorrt.py``);
#   anything unsupported simply stays on the host.  Print the partitioned module
#   and look for the ``Codegen: "tensorrt"`` functions to see what was offloaded.
# - **Dynamic shapes.** The builder sets up an optimization profile for a dynamic
#   leading (batch) dimension, so the integration can serve a model exported with
#   a symbolic batch size.
# - **Engine build cost.** Building a TensorRT engine is slow the first time (it
#   is not a hang).  Set ``TVM_TENSORRT_CACHE_DIR`` to cache built engines to
#   disk and skip the rebuild on later runs.

######################################################################
# Next steps
# ----------
#
# To build your own backend using the example NPU as a starting point:
#
# - Replace the stub runtime in
#   ``src/runtime/extra/contrib/example_npu/example_npu_runtime.cc`` with your
#   hardware SDK calls.
# - Extend ``patterns.py`` with the ops your hardware supports.
# - Add a C++ codegen under ``src/relax/backend/contrib/`` if your backend needs
#   a non-JSON serialization format.
# - Add a CMake module under ``cmake/modules/contrib/`` following
#   ``ExampleNPU.cmake``.
#
# For a complete real-backend implementation to study, see the TensorRT
# integration: the pattern table and ``partition_for_tensorrt`` in
# ``python/tvm/relax/backend/contrib/tensorrt.py``, the codegen in
# ``src/relax/backend/contrib/tensorrt/``, and the runtime in
# ``src/runtime/extra/contrib/tensorrt/``.
