403 lines
16 KiB
Python
403 lines
16 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.
|
|
# ruff: noqa: E722
|
|
"""External modules to be linked into the exported IRModule."""
|
|
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
import tvm_ffi
|
|
|
|
import tvm
|
|
from tvm import libinfo, tirx
|
|
from tvm.runtime import Module, load_static_library
|
|
from tvm.support import cc as _cc
|
|
|
|
from ...op import call_dps_packed
|
|
from . import core
|
|
from .core import wrap_nested
|
|
|
|
|
|
class ExternModule:
|
|
"""The abstract base class for external modules. External modules are designed to help
|
|
incorporate user-provided handcrafted kernels into the exported TVM IRModule.
|
|
"""
|
|
|
|
_symbols: dict[str, Callable]
|
|
|
|
def __init__(self, symbols: dict[str, Callable]) -> None:
|
|
self._symbols = symbols
|
|
|
|
def __getitem__(self, func_name: str) -> Callable:
|
|
_inference_function = self._symbols[func_name]
|
|
|
|
def _call(*input_args):
|
|
def _convert(arg, name: str):
|
|
from tvm import relax as rx # pylint: disable=import-outside-toplevel
|
|
|
|
if isinstance(arg, core.Tensor):
|
|
return arg._expr # pylint: disable=protected-access
|
|
if isinstance(arg, int):
|
|
return rx.prim_value(tirx.IntImm("int64", arg))
|
|
if isinstance(arg, float):
|
|
return rx.prim_value(tirx.FloatImm("float64", arg))
|
|
if isinstance(arg, str):
|
|
return rx.StringImm(arg)
|
|
if tvm.ir.is_prim_expr(arg):
|
|
return rx.prim_value(arg)
|
|
if isinstance(arg, tuple | list):
|
|
return rx.Tuple([_convert(e, f"{name}_{i}") for i, e in enumerate(arg)])
|
|
raise TypeError(f"Unsupported input type: {type(arg)}")
|
|
|
|
rx_inputs = _convert(input_args, "input")
|
|
rx_outputs_ty = _convert(_inference_function(*input_args), "dummy").ty
|
|
return wrap_nested(call_dps_packed(func_name, rx_inputs, rx_outputs_ty), func_name)
|
|
|
|
return _call
|
|
|
|
def _load(self, path: Path) -> Module:
|
|
return load_static_library(str(path), func_names=list(self._symbols.keys()))
|
|
|
|
def load(self) -> Module:
|
|
"""Loads the external module into a TVM runtime module."""
|
|
raise NotImplementedError
|
|
|
|
|
|
class ObjectModule(ExternModule): # pylint: disable=too-few-public-methods
|
|
"""A subclass of `nn.ExternModule`, which allows
|
|
users to provide an object `.o` file to be linked into compiled
|
|
artifact;
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
symbols: dict[str, Callable],
|
|
filepath: Path,
|
|
) -> None:
|
|
if not isinstance(filepath, Path):
|
|
filepath = Path(filepath)
|
|
if not filepath.is_file():
|
|
raise ValueError(f"Not a file: {filepath!s}")
|
|
self.filepath = filepath
|
|
super().__init__(symbols)
|
|
|
|
def load(self) -> Module:
|
|
return self._load(self.filepath)
|
|
|
|
|
|
class SourceModule(ExternModule): # pylint: disable=too-few-public-methods
|
|
"""A subclass of `nn.ExternModule`. It compiles C++/CUDA source code and link them into the
|
|
eventual IRModule.
|
|
|
|
**Shape/dtype inference.** The `nn.ExternModule` system requires users to provide additional
|
|
information to work, namely, `symbols`. It is a dictionary that maps each symbol in the
|
|
external object file to its shape/dtype inference function. Consider a case where function
|
|
`my_func` accepts two tensors, `a` of shape `(x, y, 1)`, and `b` of shape `(y, z, 5)`, and
|
|
produces a tensor `c` of shape `(x, y, z, 9)`, the shape/dtype inference function should look
|
|
like:
|
|
|
|
.. code-block:: python
|
|
|
|
def shape_dtype_inference(a, b):
|
|
x, y, _ = a.shape
|
|
_, z, _ = b.shape
|
|
return nn.Tensor.placeholder((x, y, z, 9), dtype="float32")
|
|
|
|
|
|
and the `symbols` dictionary should be provided as:
|
|
|
|
.. code-block:: python
|
|
|
|
symbols={
|
|
"my_func": shape_dtype_inference,
|
|
}
|
|
|
|
|
|
**Calling convention.** All external modules now follows "destination-passing-style" (DPS)
|
|
calling convention, which means the returned tensors are pre-allocated by the system already
|
|
and passed in as an argument of the external function.
|
|
|
|
Reuse the example above, the implementation of `my_func` should include three parameters in
|
|
its signature, where tensors are represented using DLTensor from DLPack, the de facto standard
|
|
of in-memory representation of tensors. More details:
|
|
https://github.com/dmlc/dlpack/blob/v0.8/include/dlpack/dlpack.h#L163-L206.
|
|
|
|
To expose the symbol, `TVM_FFI_DLL_EXPORT_TYPED_FUNC(symbol, function)` is guaranteed available:
|
|
|
|
.. code-block:: C++
|
|
|
|
// those headers are guaranteed to be available
|
|
#include <dlpack/dlpack.h>
|
|
#include <tvm/ffi/dtype.h>
|
|
#include <tvm/ffi/function.h>
|
|
|
|
namespace {
|
|
// anonymous namespace hides the symbol `_my_func_impl` from other translation units
|
|
int _my_func_impl(DLTensor* a, DLTensor* b, DLTensor* c) {
|
|
// `a` and `b` are inputs, and `c` is the output
|
|
}
|
|
}
|
|
// expose symbol `my_func` instead of `_my_func_impl`
|
|
TVM_FFI_DLL_EXPORT_TYPED_FUNC(my_func, _my_func_impl);
|
|
|
|
**A compiler pass `AttachExternModules`.** It is introduced to attach a list of
|
|
`nn.ExternModule`s into an IRModule at any stage of the compilation pipeline,
|
|
and attach the compiled external modules as `runtime.Module`s into IRModule's `external_mods`
|
|
attribute. It is required by linking in `tvm.compile`, but with the existence of this pass,
|
|
source compilation can be deferred to arbitrary stage of TVM compilation.
|
|
|
|
**Caveats.** It is required to call `nn.add_extern` to register external modules exactly once
|
|
during `export_tvm`. Each symbol should be registered exactly once to avoid potential conflicts,
|
|
and otherwise an error will be raised.
|
|
"""
|
|
|
|
def __init__( # pylint: disable=too-many-arguments
|
|
self,
|
|
symbols: dict[str, Callable],
|
|
source_code: str | Path,
|
|
source_format: str, # "cpp", "cu"
|
|
compile_options: list[str] | None = None,
|
|
compiler: str | None = None,
|
|
output_format: str = "obj", # "obj", "wasm"
|
|
):
|
|
"""Constructs a `nn.SourceModule` from source code.
|
|
|
|
Parameters
|
|
----------
|
|
symbols : Dict[str, Callable]
|
|
The dictionary that maps each symbol in the external object file to its shape/dtype
|
|
inference function.
|
|
|
|
source_code : Union[str, Path]
|
|
Source code or path to the source code to be compiled.
|
|
|
|
source_format : str
|
|
The source code format. It can be either "cpp" or "cu".
|
|
|
|
compile_options : Optional[List[str]]
|
|
The compile options. If not provided, the default compile options will be used.
|
|
|
|
compiler : Optional[str]
|
|
The compiler. If not provided, the default compiler will be used. On Windows,
|
|
compilation requires `clang` by default.
|
|
|
|
output_format : str
|
|
The output format. It can be either "obj" or "wasm". "obj" is the default format,
|
|
which is a shared object file. "wasm" is the WebAssembly format, which is a binary
|
|
file.
|
|
"""
|
|
|
|
def _detect_input_suffix(source_format: str) -> str:
|
|
if source_format == "cpp":
|
|
return ".cpp"
|
|
if source_format == "cu":
|
|
return ".cu"
|
|
raise ValueError(f"Invalid source format: {source_format}")
|
|
|
|
def _detect_output_suffix(output_format: str) -> str:
|
|
if output_format == "obj":
|
|
if _cc._is_linux_like(): # pylint: disable=protected-access
|
|
return ".o"
|
|
if _cc._is_windows_like(): # pylint: disable=protected-access
|
|
return ".obj"
|
|
raise ValueError(f"Unsupported platform: {sys.platform}")
|
|
if output_format == "wasm":
|
|
return ".wasm"
|
|
raise ValueError(f"Invalid output format: {output_format}")
|
|
|
|
def _detect_source_code(source_code) -> str:
|
|
if isinstance(source_code, Path):
|
|
path = source_code
|
|
if not path.is_file():
|
|
raise ValueError(f"Not a file: {path!s}")
|
|
else:
|
|
try:
|
|
path = Path(source_code)
|
|
except: # pylint: disable=bare-except
|
|
return source_code
|
|
try:
|
|
if not path.is_file():
|
|
return source_code
|
|
except: # pylint: disable=bare-except
|
|
return source_code
|
|
with path.open("r", encoding="utf-8") as file:
|
|
return file.read()
|
|
|
|
self.source_code = _detect_source_code(source_code)
|
|
if compile_options is None:
|
|
self.compile_options = SourceModule.get_compile_options(source_format=source_format)
|
|
else:
|
|
self.compile_options = list(compile_options)
|
|
self.compiler = compiler
|
|
self.source_suffix = _detect_input_suffix(source_format)
|
|
self.output_suffix = _detect_output_suffix(output_format)
|
|
super().__init__(symbols)
|
|
|
|
@staticmethod
|
|
def tvm_home() -> Path:
|
|
"""Find TVM's home directory. If `TVM_HOME` environment variable is set, use it.
|
|
Otherwise, use the directory where the `tvm` Python package is installed.
|
|
As a sanity check, it is required to have `include` and `3rdparty` as direct subdirectories.
|
|
|
|
Returns
|
|
-------
|
|
tvm_home : pathlib.Path
|
|
The TVM home directory, and it is guaranteed to have `include` and `3rdparty` as
|
|
direct subdirectories.
|
|
"""
|
|
if os.environ.get("TVM_HOME", None):
|
|
tvm_path = Path(os.environ["TVM_HOME"])
|
|
assert tvm_path.exists(), (
|
|
f"Using environment variable `TVM_HOME`, but directory not found: {tvm_path!s}"
|
|
)
|
|
assert tvm_path.is_dir(), (
|
|
f"Using environment variable `TVM_HOME`, but it is not a directory: {tvm_path!s}"
|
|
)
|
|
else:
|
|
import tvm # pylint: disable=import-outside-toplevel
|
|
|
|
tvm_path = Path(tvm.__file__).parent
|
|
assert tvm_path.is_dir()
|
|
tvm_path = tvm_path.resolve()
|
|
while True:
|
|
exists_include = (tvm_path / "include").is_dir()
|
|
exists_3rdparty = (tvm_path / "3rdparty").is_dir()
|
|
if exists_include and exists_3rdparty:
|
|
return tvm_path.resolve()
|
|
parent = tvm_path.parent
|
|
if parent == tvm_path:
|
|
raise ValueError(
|
|
"Cannot detect TVM directory. "
|
|
"Please explicitly specify it by setting `TVM_HOME` environment variable, "
|
|
"and make sure it contains `include` and `3rdparty` as direct sub-directories."
|
|
)
|
|
tvm_path = parent
|
|
return tvm_path.resolve()
|
|
|
|
@staticmethod
|
|
def get_includes(tvm_pkg: list[str] | None = None) -> list[Path]:
|
|
"""Returns the default include paths according to `tvm_home()`.
|
|
By default, it includes TVM, DLPack. With `tvm_pkg` provided, it also
|
|
includes the specified package under `tvm_home/3rdparty`.
|
|
|
|
Parameters
|
|
----------
|
|
tvm_pkg : Optional[List[str]]
|
|
The list of packages to be included under `tvm_home/3rdparty`. Each element should be
|
|
a relative path to `tvm_home/3rdparty`.
|
|
|
|
Returns
|
|
-------
|
|
includes : List[pathlib.Path]
|
|
The list of include paths.
|
|
"""
|
|
results = [
|
|
Path(libinfo.find_include_path()),
|
|
Path(tvm_ffi.libinfo.find_include_path()),
|
|
Path(tvm_ffi.libinfo.find_dlpack_include_path()),
|
|
]
|
|
if tvm_pkg:
|
|
tvm_home = SourceModule.tvm_home()
|
|
for relative in tvm_pkg:
|
|
results.append(tvm_home / "3rdparty" / relative)
|
|
results = list(dict.fromkeys(results))
|
|
for path in results:
|
|
assert path.exists(), f"Not found: {path!s}"
|
|
assert path.is_dir(), f"Not a directory: {path!s}"
|
|
return results
|
|
|
|
@staticmethod
|
|
def get_compile_options(
|
|
source_format: str,
|
|
tvm_pkg: list[str] | None = None,
|
|
) -> list[str]:
|
|
"""Returns the default compile options depending on `source_format`, including the default
|
|
inlcude paths w.r.t. `tvm_home()`, and by default,
|
|
it uses "-O3" and "-std=c++17".
|
|
|
|
Parameters
|
|
----------
|
|
source_format : str
|
|
The source code format. It can be either "cpp" or "cu".
|
|
|
|
tvm_pkg : Optional[List[str]]
|
|
The list of packages to be included under `tvm_home/3rdparty`. Each element should be
|
|
a relative path to `tvm_home/3rdparty`.
|
|
|
|
Returns
|
|
-------
|
|
compile_options : List[str]
|
|
The list of compilation flags.
|
|
"""
|
|
include_flags = []
|
|
for include_path in SourceModule.get_includes(tvm_pkg=tvm_pkg):
|
|
include_flags += ["-I", str(include_path)]
|
|
if source_format == "cpp":
|
|
host_flags = [
|
|
"-c", # generate object file
|
|
"-O3",
|
|
"-std=c++17",
|
|
]
|
|
elif source_format == "cu":
|
|
host_flags = [
|
|
"-c", # generate object file
|
|
"-O3",
|
|
"-std=c++17",
|
|
# Enable `-fPIC` for the host compiler
|
|
"-Xcompiler=-fPIC",
|
|
]
|
|
else:
|
|
raise ValueError(f"Invalid source format: {source_format}")
|
|
return include_flags + host_flags
|
|
|
|
def compile(self, output_path: Path) -> None:
|
|
"""Compiles the source code in a provided directory and returns the compiled artifact."""
|
|
with tempfile.TemporaryDirectory() as temp_dir_str:
|
|
temp_dir = Path(temp_dir_str)
|
|
source_filename = f"main{self.source_suffix}"
|
|
object_filename = f"main{self.output_suffix}"
|
|
source_path = temp_dir / source_filename
|
|
object_path = temp_dir / object_filename
|
|
with source_path.open("w", encoding="utf-8") as file:
|
|
file.write(self.source_code)
|
|
_cc.create_shared(
|
|
output=object_filename,
|
|
objects=[source_filename],
|
|
options=self.compile_options,
|
|
cc=self.compiler,
|
|
cwd=temp_dir,
|
|
ccache_env=(
|
|
{
|
|
"CCACHE_COMPILERCHECK": "content",
|
|
"CCACHE_NOHASHDIR": "1",
|
|
}
|
|
if shutil.which("ccache")
|
|
else None
|
|
),
|
|
)
|
|
shutil.move(str(object_path), str(output_path))
|
|
|
|
def load(self) -> Module:
|
|
with tempfile.TemporaryDirectory() as temp_dir_str:
|
|
output_path = Path(temp_dir_str) / f"main{self.output_suffix}"
|
|
self.compile(output_path)
|
|
return self._load(output_path)
|