376 lines
17 KiB
Python
376 lines
17 KiB
Python
# 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/``.
|