自定義 C++ 和 CUDA 運算元¶
創建於: 2024年6月18日 | 最後更新於: 2025年1月28日 | 最後驗證於: 2024年11月5日
作者: Richard Zou
如何將用 C++/CUDA 編寫的自定義運算元整合到 PyTorch 中
如何使用
torch.library.opcheck測試自定義運算元
PyTorch 2.4 或更高版本
具備 C++ 和 CUDA 程式設計基礎知識
注意
本教程同樣適用於 AMD ROCm,無需額外修改。
PyTorch 提供了大量的運算元用於處理張量(例如 torch.add, torch.sum 等)。然而,你可能希望向 PyTorch 引入新的自定義運算元。本教程演示了編寫 C++/CUDA 自定義運算元的推薦方法。
在本教程中,我們將演示如何編寫一個融合的乘加 C++ 和 CUDA 運算元,並使其與 PyTorch 子系統相容。該操作的語義如下:
def mymuladd(a: Tensor, b: Tensor, c: float):
return a * b + c
你可以在此處找到本教程的端到端工作示例。
設定構建系統¶
如果你正在開發自定義 C++/CUDA 程式碼,則必須對其進行編譯。請注意,如果你只是與已繫結到預編譯 C++/CUDA 程式碼的 Python 庫進行介面,你可能需要考慮編寫自定義 Python 運算元(自定義 Python 運算元)。
使用torch.utils.cpp_extension 來編譯自定義 C++/CUDA 程式碼,以便與 PyTorch C++ 擴充套件一起使用。擴充套件可以透過 setuptools 進行“提前”構建,也可以透過load_inline進行“即時”構建;我們將重點關注“提前”構建的方式。
使用 cpp_extension 就像編寫如下 setup.py 檔案一樣簡單:
from setuptools import setup, Extension
from torch.utils import cpp_extension
setup(name="extension_cpp",
ext_modules=[
cpp_extension.CppExtension(
"extension_cpp",
["muladd.cpp"],
# define Py_LIMITED_API with min version 3.9 to expose only the stable
# limited API subset from Python.h
extra_compile_args={"cxx": ["-DPy_LIMITED_API=0x03090000"]},
py_limited_api=True)], # Build 1 wheel across multiple Python versions
cmdclass={'build_ext': cpp_extension.BuildExtension},
options={"bdist_wheel": {"py_limited_api": "cp39"}} # 3.9 is minimum supported Python version
)
如果你需要編譯 CUDA 程式碼(例如 .cu 檔案),則應改用torch.utils.cpp_extension.CUDAExtension。請參閱extension-cpp 以獲取如何設定此項的示例。
上面的示例代表了我們所謂的 CPython 無關 wheel,意味著我們構建一個可在多個 CPython 版本上執行的 wheel(類似於純 Python 包)。CPython 無關性有助於最大限度地減少自定義庫需要支援和釋出的 wheel 數量。我們希望支援的最低版本是 3.9,因為它是當前支援的最舊版本,因此我們在整個 setup 程式碼中使用了相應的 hexcode 和 specifier。我們建議在與你想要支援的最低 CPython 版本相同的環境中構建擴充套件,以儘量減少未知行為,因此,在此,我們在 CPython 3.9 環境中構建擴充套件。構建完成後,這一個 wheel 將可以在任何 CPython 3.9+ 環境中執行。為了實現這一點,有三行關鍵程式碼需要注意。
第一行是在 extra_compile_args 中指定 Py_LIMITED_API 為你想要支援的最低 CPython 版本:
extra_compile_args={"cxx": ["-DPy_LIMITED_API=0x03090000"]},
定義 Py_LIMITED_API 標誌有助於驗證擴充套件實際上僅使用了CPython 穩定有限 API,這是構建 CPython 無關 wheel 的要求。如果未滿足此要求,可能會構建一個看起來是 CPython 無關的 wheel,但在另一個 CPython 環境中崩潰,甚至更糟,會靜默地不正確。請注意避免使用不穩定的 CPython API,例如 libtorch_python 中的 API(特別是 pytorch/python 繫結),並且僅使用 libtorch 中的 API(ATen 物件、運算元和排程器)。我們強烈建議定義 Py_LIMITED_API 標誌,以幫助確定擴充套件是否符合要求並可作為 CPython 無關 wheel 安全使用。請注意,定義此標誌並不能完全保證構建的 wheel 是 CPython 無關的,但這比在完全不可控的情況下要好得多。Python 文件中提到了幾個注意事項,你應該自行測試和驗證 wheel 是否在相關的 CPython 版本中真正無關。
第二行和第三行指定 py_limited_api,這會通知 setuptools 你打算構建一個 CPython 無關 wheel,並會影響 wheel 的命名:
setup(name="extension_cpp",
ext_modules=[
cpp_extension.CppExtension(
...,
py_limited_api=True)], # Build 1 wheel across multiple Python versions
...,
options={"bdist_wheel": {"py_limited_api": "cp39"}} # 3.9 is minimum supported Python version
)
有必要將 py_limited_api=True 指定為 CppExtension/CUDAExtension 的引數,並且也作為 "bdist_wheel" 命令的一個選項,並帶有最低支援的 CPython 版本(在本例中為 3.9)。因此,本教程中的 setup 將構建一個正確命名的 wheel,可以在多個 CPython 版本 >=3.9 上安裝。
如果你的擴充套件使用了穩定有限集之外的 CPython API,那麼你就不能構建 CPython 無關 wheel!你應該針對每個 CPython 版本構建一個 wheel,如下所示:
from setuptools import setup, Extension
from torch.utils import cpp_extension
setup(name="extension_cpp",
ext_modules=[
cpp_extension.CppExtension(
"extension_cpp",
["muladd.cpp"])],
cmdclass={'build_ext': cpp_extension.BuildExtension},
)
定義自定義運算元並新增後端實現¶
首先,讓我們編寫一個計算 mymuladd 的 C++ 函式:
at::Tensor mymuladd_cpu(at::Tensor a, const at::Tensor& b, double c) {
TORCH_CHECK(a.sizes() == b.sizes());
TORCH_CHECK(a.dtype() == at::kFloat);
TORCH_CHECK(b.dtype() == at::kFloat);
TORCH_INTERNAL_ASSERT(a.device().type() == at::DeviceType::CPU);
TORCH_INTERNAL_ASSERT(b.device().type() == at::DeviceType::CPU);
at::Tensor a_contig = a.contiguous();
at::Tensor b_contig = b.contiguous();
at::Tensor result = torch::empty(a_contig.sizes(), a_contig.options());
const float* a_ptr = a_contig.data_ptr<float>();
const float* b_ptr = b_contig.data_ptr<float>();
float* result_ptr = result.data_ptr<float>();
for (int64_t i = 0; i < result.numel(); i++) {
result_ptr[i] = a_ptr[i] * b_ptr[i] + c;
}
return result;
}
為了在 PyTorch 的 Python 前端中使用它,我們需要使用 TORCH_LIBRARY API 將其註冊為 PyTorch 運算元。這將自動將該運算元繫結到 Python。
運算元註冊是兩步過程:
定義運算元 - 此步驟確保 PyTorch 知道新運算元。
註冊後端實現 - 在此步驟中,將各種後端(如 CPU 和 CUDA)的實現與運算元關聯。
定義運算元¶
要定義一個運算元,請遵循以下步驟:
為運算元選擇一個名稱空間。我們建議名稱空間是你頂層專案的名稱;在本教程中,我們將使用“extension_cpp”。
提供一個模式字串,指定運算元的輸入/輸出型別以及輸入張量是否會被修改。除了 Tensor 和 float,我們還支援更多型別;請參閱自定義運算元手冊瞭解更多詳細資訊。
如果你正在編寫一個可以修改其輸入張量的運算元,請參閱此處(建立可變運算元)瞭解如何指定。
TORCH_LIBRARY(extension_cpp, m) {
// Note that "float" in the schema corresponds to the C++ double type
// and the Python float type.
m.def("mymuladd(Tensor a, Tensor b, float c) -> Tensor");
}
這使得該運算元可以透過 torch.ops.extension_cpp.mymuladd 在 Python 中可用。
註冊運算元的後端實現¶
使用 TORCH_LIBRARY_IMPL 為運算元註冊後端實現。
TORCH_LIBRARY_IMPL(extension_cpp, CPU, m) {
m.impl("mymuladd", &mymuladd_cpu);
}
如果你也有 myaddmul 的 CUDA 實現,可以在單獨的 TORCH_LIBRARY_IMPL 塊中註冊它:
__global__ void muladd_kernel(int numel, const float* a, const float* b, float c, float* result) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < numel) result[idx] = a[idx] * b[idx] + c;
}
at::Tensor mymuladd_cuda(const at::Tensor& a, const at::Tensor& b, double c) {
TORCH_CHECK(a.sizes() == b.sizes());
TORCH_CHECK(a.dtype() == at::kFloat);
TORCH_CHECK(b.dtype() == at::kFloat);
TORCH_INTERNAL_ASSERT(a.device().type() == at::DeviceType::CUDA);
TORCH_INTERNAL_ASSERT(b.device().type() == at::DeviceType::CUDA);
at::Tensor a_contig = a.contiguous();
at::Tensor b_contig = b.contiguous();
at::Tensor result = torch::empty(a_contig.sizes(), a_contig.options());
const float* a_ptr = a_contig.data_ptr<float>();
const float* b_ptr = b_contig.data_ptr<float>();
float* result_ptr = result.data_ptr<float>();
int numel = a_contig.numel();
muladd_kernel<<<(numel+255)/256, 256>>>(numel, a_ptr, b_ptr, c, result_ptr);
return result;
}
TORCH_LIBRARY_IMPL(extension_cpp, CUDA, m) {
m.impl("mymuladd", &mymuladd_cuda);
}
為運算元新增 torch.compile 支援¶
為了為運算元新增 torch.compile 支援,我們必須新增一個 FakeTensor 核(也稱為“meta 核”或“抽象實現”)。FakeTensor 是具有元資料(如形狀、dtype、裝置)但沒有資料的張量:運算元的 FakeTensor 核指定了如何根據輸入張量的元資料計算輸出張量的元資料。FakeTensor 核應返回你選擇的帶有正確張量元資料(形狀/跨步/dtype/裝置)的虛擬張量。
我們建議透過 torch.library.register_fake API 從 Python 中完成此操作,儘管也可以從 C++ 中完成(詳情請參閱自定義運算元手冊)。
# Important: the C++ custom operator definitions should be loaded first
# before calling ``torch.library`` APIs that add registrations for the
# C++ custom operator(s). The following import loads our
# C++ custom operator definitions.
# Note that if you are striving for Python agnosticism, you should use
# the ``load_library(...)`` API call instead. See the next section for
# more details.
from . import _C
@torch.library.register_fake("extension_cpp::mymuladd")
def _(a, b, c):
torch._check(a.shape == b.shape)
torch._check(a.dtype == torch.float)
torch._check(b.dtype == torch.float)
torch._check(a.device == b.device)
return torch.empty_like(a)
設定混合 Python/C++ 註冊¶
在本教程中,我們在 C++ 中定義了一個自定義運算元,在 C++ 中添加了 CPU/CUDA 實現,並在 Python 中添加了 FakeTensor 核和反向公式。這些註冊被載入(或匯入)的順序很重要(以錯誤的順序匯入會導致錯誤)。
要使用混合 Python/C++ 註冊的自定義運算元,我們必須首先載入包含自定義運算元定義的 C++ 庫,然後呼叫 torch.library 註冊 API。這可以透過三種方式實現:
載入包含自定義運算元定義的 C++ 庫的第一種方法是為 _C 定義一個虛擬 Python 模組。然後,在 Python 中,當你使用
import _C匯入模組時,與擴充套件對應的.so檔案將被載入,並且TORCH_LIBRARY和TORCH_LIBRARY_IMPL靜態初始化器將執行。可以使用如下所示的PYBIND11_MODULE建立一個虛擬 Python 模組,但你會注意到這不能與Py_LIMITED_API一起編譯,因為pybind11不保證只使用穩定的有限 CPython API!有了下面的程式碼,你遺憾地無法為你的擴充套件構建 CPython 無關 wheel!(預示:我想知道第二種方法是什麼 ;))。
// in, say, not_agnostic/csrc/extension_BAD.cpp
#include <pybind11/pybind11.h>
PYBIND11_MODULE("_C", m) {}
# in, say, extension/__init__.py
from . import _C
在本教程中,由於我們重視能夠構建一個跨多個 CPython 版本的 wheel,我們將用穩定的 API 呼叫替換不穩定的
PYBIND11呼叫。下面的程式碼可以用-DPy_LIMITED_API=0x03090000編譯,併成功為我們的_C擴充套件建立一個虛擬 Python 模組,以便可以從 Python 中匯入。詳情請參閱extension_cpp/__init__.py 和extension_cpp/csrc/muladd.cpp
#include <Python.h>
extern "C" {
/* Creates a dummy empty _C module that can be imported from Python.
The import from Python will load the .so consisting of this file
in this extension, so that the TORCH_LIBRARY static initializers
below are run. */
PyObject* PyInit__C(void)
{
static struct PyModuleDef module_def = {
PyModuleDef_HEAD_INIT,
"_C", /* name of module */
NULL, /* module documentation, may be NULL */
-1, /* size of per-interpreter state of the module,
or -1 if the module keeps state in global variables. */
NULL, /* methods */
};
return PyModule_Create(&module_def);
}
}
# in, say, extension/__init__.py
from . import _C
如果你想在 C++ 自定義運算元中完全避免使用
Python.h,可以在 Python 中使用torch.ops.load_library("/path/to/library.so")來載入從擴充套件編譯的.so檔案。請注意,使用此方法,不會為擴充套件建立_CPython 模組,因此你無法從 Python 中呼叫import _C。與其依賴匯入語句觸發自定義運算元註冊,torch.ops.load_library("/path/to/library.so")可以完成這項工作。挑戰在於理解.so檔案位於何處,以便你可以載入它們,但這並不總是那麼容易:
import torch
from pathlib import Path
so_files = list(Path(__file__).parent.glob("_C*.so"))
assert (
len(so_files) == 1
), f"Expected one _C*.so file, found {len(so_files)}"
torch.ops.load_library(so_files[0])
from . import ops
為運算元新增訓練 (autograd) 支援¶
使用 torch.library.register_autograd 為運算元新增訓練支援。建議優先使用此方法,而不是直接使用 Python torch.autograd.Function 或 C++ torch::autograd::Function;使用它們的方式必須非常特定,以避免靜默錯誤(詳細資訊請參閱自定義運算元手冊)。
def _backward(ctx, grad):
a, b = ctx.saved_tensors
grad_a, grad_b = None, None
if ctx.needs_input_grad[0]:
grad_a = grad * b
if ctx.needs_input_grad[1]:
grad_b = grad * a
return grad_a, grad_b, None
def _setup_context(ctx, inputs, output):
a, b, c = inputs
saved_a, saved_b = None, None
if ctx.needs_input_grad[0]:
saved_b = b
if ctx.needs_input_grad[1]:
saved_a = a
ctx.save_for_backward(saved_a, saved_b)
# This code adds training support for the operator. You must provide us
# the backward formula for the operator and a `setup_context` function
# to save values to be used in the backward.
torch.library.register_autograd(
"extension_cpp::mymuladd", _backward, setup_context=_setup_context)
請注意,反向傳播必須由 PyTorch 可理解的運算元組成。如果你希望在反向傳播中使用另一個自定義 C++ 或 CUDA 核,它必須被封裝成一個自定義運算元。
如果我們有自己的自定義 mymul 核,我們需要將其封裝成一個自定義運算元,然後從反向傳播中呼叫它:
// New! a mymul_cpu kernel
at::Tensor mymul_cpu(const at::Tensor& a, const at::Tensor& b) {
TORCH_CHECK(a.sizes() == b.sizes());
TORCH_CHECK(a.dtype() == at::kFloat);
TORCH_CHECK(b.dtype() == at::kFloat);
TORCH_CHECK(a.device().type() == at::DeviceType::CPU);
TORCH_CHECK(b.device().type() == at::DeviceType::CPU);
at::Tensor a_contig = a.contiguous();
at::Tensor b_contig = b.contiguous();
at::Tensor result = torch::empty(a_contig.sizes(), a_contig.options());
const float* a_ptr = a_contig.data_ptr<float>();
const float* b_ptr = b_contig.data_ptr<float>();
float* result_ptr = result.data_ptr<float>();
for (int64_t i = 0; i < result.numel(); i++) {
result_ptr[i] = a_ptr[i] * b_ptr[i];
}
return result;
}
TORCH_LIBRARY(extension_cpp, m) {
m.def("mymuladd(Tensor a, Tensor b, float c) -> Tensor");
// New! defining the mymul operator
m.def("mymul(Tensor a, Tensor b) -> Tensor");
}
TORCH_LIBRARY_IMPL(extension_cpp, CPU, m) {
m.impl("mymuladd", &mymuladd_cpu);
// New! registering the cpu kernel for the mymul operator
m.impl("mymul", &mymul_cpu);
}
def _backward(ctx, grad):
a, b = ctx.saved_tensors
grad_a, grad_b = None, None
if ctx.needs_input_grad[0]:
grad_a = torch.ops.extension_cpp.mymul.default(grad, b)
if ctx.needs_input_grad[1]:
grad_b = torch.ops.extension_cpp.mymul.default(grad, a)
return grad_a, grad_b, None
def _setup_context(ctx, inputs, output):
a, b, c = inputs
saved_a, saved_b = None, None
if ctx.needs_input_grad[0]:
saved_b = b
if ctx.needs_input_grad[1]:
saved_a = a
ctx.save_for_backward(saved_a, saved_b)
# This code adds training support for the operator. You must provide us
# the backward formula for the operator and a `setup_context` function
# to save values to be used in the backward.
torch.library.register_autograd(
"extension_cpp::mymuladd", _backward, setup_context=_setup_context)
測試運算元¶
使用 torch.library.opcheck 來測試自定義運算元是否正確註冊。請注意,此函式不測試梯度在數學上是否正確——請計劃為此編寫單獨的測試,無論是手動測試還是使用 torch.autograd.gradcheck。
def sample_inputs(device, *, requires_grad=False):
def make_tensor(*size):
return torch.randn(size, device=device, requires_grad=requires_grad)
def make_nondiff_tensor(*size):
return torch.randn(size, device=device, requires_grad=False)
return [
[make_tensor(3), make_tensor(3), 1],
[make_tensor(20), make_tensor(20), 3.14],
[make_tensor(20), make_nondiff_tensor(20), -123],
[make_nondiff_tensor(2, 3), make_tensor(2, 3), -0.3],
]
def reference_muladd(a, b, c):
return a * b + c
samples = sample_inputs(device, requires_grad=True)
samples.extend(sample_inputs(device, requires_grad=False))
for args in samples:
# Correctness test
result = torch.ops.extension_cpp.mymuladd(*args)
expected = reference_muladd(*args)
torch.testing.assert_close(result, expected)
# Use opcheck to check for incorrect usage of operator registration APIs
torch.library.opcheck(torch.ops.extension_cpp.mymuladd.default, args)
建立可變運算元¶
你可能希望編寫一個修改其輸入的自定義運算元。使用 Tensor(a!) 來指定模式中每個可變張量;否則,行為將未定義。如果有多個可變張量,請為每個可變張量使用不同的名稱(例如,Tensor(a!)、Tensor(b!)、Tensor(c!))。
讓我們編寫一個 myadd_out(a, b, out) 運算元,它將 a+b 的內容寫入 out。
// An example of an operator that mutates one of its inputs.
void myadd_out_cpu(const at::Tensor& a, const at::Tensor& b, at::Tensor& out) {
TORCH_CHECK(a.sizes() == b.sizes());
TORCH_CHECK(b.sizes() == out.sizes());
TORCH_CHECK(a.dtype() == at::kFloat);
TORCH_CHECK(b.dtype() == at::kFloat);
TORCH_CHECK(out.dtype() == at::kFloat);
TORCH_CHECK(out.is_contiguous());
TORCH_INTERNAL_ASSERT(a.device().type() == at::DeviceType::CPU);
TORCH_INTERNAL_ASSERT(b.device().type() == at::DeviceType::CPU);
TORCH_INTERNAL_ASSERT(out.device().type() == at::DeviceType::CPU);
at::Tensor a_contig = a.contiguous();
at::Tensor b_contig = b.contiguous();
const float* a_ptr = a_contig.data_ptr<float>();
const float* b_ptr = b_contig.data_ptr<float>();
float* result_ptr = out.data_ptr<float>();
for (int64_t i = 0; i < out.numel(); i++) {
result_ptr[i] = a_ptr[i] + b_ptr[i];
}
}
定義運算元時,必須在模式中指定它會修改 out 張量:
TORCH_LIBRARY(extension_cpp, m) {
m.def("mymuladd(Tensor a, Tensor b, float c) -> Tensor");
m.def("mymul(Tensor a, Tensor b) -> Tensor");
// New!
m.def("myadd_out(Tensor a, Tensor b, Tensor(a!) out) -> ()");
}
TORCH_LIBRARY_IMPL(extension_cpp, CPU, m) {
m.impl("mymuladd", &mymuladd_cpu);
m.impl("mymul", &mymul_cpu);
// New!
m.impl("myadd_out", &myadd_out_cpu);
}
注意
不要將任何被修改的張量作為運算元的輸出返回,因為這會導致與 PyTorch 子系統(如 torch.compile)不相容。