自定义 C++ 和 CUDA 扩展¶
创建于:2018 年 4 月 26 日 | 最后更新:2024 年 7 月 22 日 | 最后验证:2024 年 11 月 05 日
警告
本教程已于 PyTorch 2.4 版本弃用。请参阅 PyTorch 自定义运算符 以获取关于使用自定义 C++/CUDA 扩展来扩展 PyTorch 的最新指南。
PyTorch 提供了大量与神经网络、任意张量代数、数据整理和其他目的相关的操作。但是,您可能仍然需要更自定义的操作。例如,您可能想使用在论文中找到的新型激活函数,或者实现您作为研究一部分开发的操作。
在 PyTorch 中集成此类自定义操作的最简单方法是在 Python 中编写它,方法是扩展 Function
和 Module
,如此处所述。这为您提供了自动微分的全部功能(省去了编写导数函数的麻烦)以及 Python 通常的表达能力。但是,有时您的操作在 C++ 中实现会更好。例如,您的代码可能需要非常快,因为它在您的模型中被频繁调用,或者即使对于少量调用也非常昂贵。另一个可能的原因是它依赖于其他 C 或 C++ 库或与之交互。为了解决这些情况,PyTorch 提供了一种非常简单的方法来编写自定义 C++ 扩展。
C++ 扩展是我们开发的一种机制,允许用户(您)创建源外定义的 PyTorch 运算符,即与 PyTorch 后端分离。这种方法与本机 PyTorch 操作的实现方式不同。 C++ 扩展旨在为您节省与将操作与 PyTorch 后端集成相关的许多样板代码,同时为您基于 PyTorch 的项目提供高度的灵活性。尽管如此,一旦您将操作定义为 C++ 扩展,将其转换为本机 PyTorch 函数在很大程度上只是代码组织的问题,如果您决定向上游贡献您的操作,您可以在事后解决这个问题。
动机和示例¶
本说明的其余部分将通过一个编写和使用 C++(和 CUDA)扩展的实际示例。如果您正被追赶,或者有人会因为您在一天结束前没有完成该操作而解雇您,您可以跳过本节,直接进入下一节中的实现细节。
假设您提出了一种新型的循环单元,您发现它比最先进的技术具有卓越的性能。这种循环单元类似于 LSTM,但不同之处在于它缺少遗忘门,并使用指数线性单元 (ELU) 作为其内部激活函数。由于该单元永不遗忘,我们将其称为 LLTM,或 长-长期-记忆 单元。
LLTM 与 vanilla LSTM 不同的两种方式非常重要,以至于我们无法为我们的目的配置 PyTorch 的 LSTMCell
,因此我们必须创建一个自定义单元。实现此目的的第一种也是最简单的方法——并且可能在所有情况下都是一个好的第一步——是用纯 PyTorch 和 Python 实现我们期望的功能。为此,我们需要继承 torch.nn.Module
并实现 LLTM 的前向传递。这看起来像这样
class LLTM(torch.nn.Module):
def __init__(self, input_features, state_size):
super(LLTM, self).__init__()
self.input_features = input_features
self.state_size = state_size
# 3 * state_size for input gate, output gate and candidate cell gate.
# input_features + state_size because we will multiply with [input, h].
self.weights = torch.nn.Parameter(
torch.empty(3 * state_size, input_features + state_size))
self.bias = torch.nn.Parameter(torch.empty(3 * state_size))
self.reset_parameters()
def reset_parameters(self):
stdv = 1.0 / math.sqrt(self.state_size)
for weight in self.parameters():
weight.data.uniform_(-stdv, +stdv)
def forward(self, input, state):
old_h, old_cell = state
X = torch.cat([old_h, input], dim=1)
# Compute the input, output and candidate cell gates with one MM.
gate_weights = F.linear(X, self.weights, self.bias)
# Split the combined gate weight matrix into its components.
gates = gate_weights.chunk(3, dim=1)
input_gate = torch.sigmoid(gates[0])
output_gate = torch.sigmoid(gates[1])
# Here we use an ELU instead of the usual tanh.
candidate_cell = F.elu(gates[2])
# Compute the new cell state.
new_cell = old_cell + candidate_cell * input_gate
# Compute the new hidden state and output.
new_h = torch.tanh(new_cell) * output_gate
return new_h, new_cell
然后我们可以像预期的那样使用它
import torch
X = torch.randn(batch_size, input_features)
h = torch.randn(batch_size, state_size)
C = torch.randn(batch_size, state_size)
rnn = LLTM(input_features, state_size)
new_h, new_C = rnn(X, (h, C))
自然地,如果可能且合理,您应该使用这种方法来扩展 PyTorch。由于 PyTorch 对其 CPU 和 GPU 操作进行了高度优化的实现,并由诸如 NVIDIA cuDNN、Intel MKL 或 NNPACK 之类的库提供支持,因此像上面这样的 PyTorch 代码通常足够快。但是,我们也可以看到,在某些情况下,还有进一步提高性能的空间。最明显的原因是 PyTorch 不知道您正在实现的算法。它只知道您用于组合算法的单个操作。因此,PyTorch 必须逐个执行您的操作,一个接一个。由于每次对操作的实现(或内核)的单独调用(可能涉及启动 CUDA 内核)都有一定的开销,因此这种开销在许多函数调用中可能会变得非常大。此外,运行我们代码的 Python 解释器本身也会减慢我们的程序。
因此,加速的明确方法是用 C++(或 CUDA)重写部分代码并融合特定组的操作。融合意味着将许多函数的实现组合成一个函数,这得益于更少的内核启动以及我们可以通过提高全局数据流的可见性来执行的其他优化。
让我们看看如何使用 C++ 扩展来实现 LLTM 的融合版本。我们将首先用纯 C++ 编写它,使用为 PyTorch 后端提供支持的 ATen 库,并了解它如何轻松地让我们转换我们的 Python 代码。然后,我们将通过将模型的部分移动到 CUDA 内核来进一步加速,以受益于 GPU 提供的巨大并行性。
编写 C++ 扩展¶
C++ 扩展有两种风格:它们可以使用 setuptools
“提前”构建,或者通过 torch.utils.cpp_extension.load()
“即时”构建。我们将从第一种方法开始,稍后讨论后一种方法。
使用 setuptools
构建¶
对于“提前”风格,我们通过编写一个 setup.py
脚本来构建我们的 C++ 扩展,该脚本使用 setuptools 来编译我们的 C++ 代码。对于 LLTM,它看起来像这样简单
from setuptools import setup, Extension
from torch.utils import cpp_extension
setup(name='lltm_cpp',
ext_modules=[cpp_extension.CppExtension('lltm_cpp', ['lltm.cpp'])],
cmdclass={'build_ext': cpp_extension.BuildExtension})
在此代码中,CppExtension
是 setuptools.Extension
的一个方便的包装器,它传递正确的包含路径并将扩展的语言设置为 C++。等效的 vanilla setuptools
代码将只是
Extension(
name='lltm_cpp',
sources=['lltm.cpp'],
include_dirs=cpp_extension.include_paths(),
language='c++')
BuildExtension
执行许多必需的配置步骤和检查,并且在混合 C++/CUDA 扩展的情况下还管理混合编译。这就是我们现在真正需要了解的关于构建 C++ 扩展的全部内容!现在让我们看一下我们的 C++ 扩展的实现,它进入 lltm.cpp
。
编写 C++ Op¶
让我们开始在 C++ 中实现 LLTM!反向传递所需的一个函数是 sigmoid 的导数。这是一个足够小的代码片段,可以讨论我们在编写 C++ 扩展时可用的整体环境
#include <torch/extension.h>
#include <iostream>
torch::Tensor d_sigmoid(torch::Tensor z) {
auto s = torch::sigmoid(z);
return (1 - s) * s;
}
<torch/extension.h>
是包含编写 C++ 扩展所需的所有 PyTorch 位的一站式头文件。它包括
ATen 库,它是我们用于张量计算的主要 API,
pybind11,这是我们如何为 C++ 代码创建 Python 绑定的,
管理 ATen 和 pybind11 之间交互细节的头文件。
d_sigmoid()
的实现显示了如何使用 ATen API。 PyTorch 的张量和变量接口是从 ATen 库自动生成的,因此我们可以或多或少地将我们的 Python 实现 1:1 转换为 C++。我们所有计算的主要数据类型将是 torch::Tensor
。它的完整 API 可以在此处查看。另请注意,我们可以包含 <iostream>
或任何其他 C 或 C++ 头文件——我们拥有 C++11 的全部功能。
请注意,CUDA-11.5 nvcc 在 Windows 上解析 torch/extension.h 时会遇到内部编译器错误。为了解决这个问题,请将 python 绑定逻辑移至纯 C++ 文件。示例用法
#include <ATen/ATen.h>
at::Tensor SigmoidAlphaBlendForwardCuda(....)
而不是
#include <torch/extension.h>
torch::Tensor SigmoidAlphaBlendForwardCuda(...)
当前针对 nvcc 错误的未解决问题此处。完整的解决方法代码示例此处。
前向传递¶
接下来,我们可以将整个前向传递移植到 C++
#include <vector>
std::vector<at::Tensor> lltm_forward(
torch::Tensor input,
torch::Tensor weights,
torch::Tensor bias,
torch::Tensor old_h,
torch::Tensor old_cell) {
auto X = torch::cat({old_h, input}, /*dim=*/1);
auto gate_weights = torch::addmm(bias, X, weights.transpose(0, 1));
auto gates = gate_weights.chunk(3, /*dim=*/1);
auto input_gate = torch::sigmoid(gates[0]);
auto output_gate = torch::sigmoid(gates[1]);
auto candidate_cell = torch::elu(gates[2], /*alpha=*/1.0);
auto new_cell = old_cell + candidate_cell * input_gate;
auto new_h = torch::tanh(new_cell) * output_gate;
return {new_h,
new_cell,
input_gate,
output_gate,
candidate_cell,
X,
gate_weights};
}
反向传递¶
C++ 扩展 API 当前不提供自动为我们生成反向函数的方法。因此,我们还必须实现 LLTM 的反向传递,该传递计算损失相对于前向传递的每个输入的导数。最终,我们将把前向和反向函数都放入 torch.autograd.Function
中以创建漂亮的 Python 绑定。反向函数稍微复杂一些,因此我们不会深入研究代码(如果您有兴趣,Alex Graves 的论文是获取更多信息的绝佳读物)
// tanh'(z) = 1 - tanh^2(z)
torch::Tensor d_tanh(torch::Tensor z) {
return 1 - z.tanh().pow(2);
}
// elu'(z) = relu'(z) + { alpha * exp(z) if (alpha * (exp(z) - 1)) < 0, else 0}
torch::Tensor d_elu(torch::Tensor z, torch::Scalar alpha = 1.0) {
auto e = z.exp();
auto mask = (alpha * (e - 1)) < 0;
return (z > 0).type_as(z) + mask.type_as(z) * (alpha * e);
}
std::vector<torch::Tensor> lltm_backward(
torch::Tensor grad_h,
torch::Tensor grad_cell,
torch::Tensor new_cell,
torch::Tensor input_gate,
torch::Tensor output_gate,
torch::Tensor candidate_cell,
torch::Tensor X,
torch::Tensor gate_weights,
torch::Tensor weights) {
auto d_output_gate = torch::tanh(new_cell) * grad_h;
auto d_tanh_new_cell = output_gate * grad_h;
auto d_new_cell = d_tanh(new_cell) * d_tanh_new_cell + grad_cell;
auto d_old_cell = d_new_cell;
auto d_candidate_cell = input_gate * d_new_cell;
auto d_input_gate = candidate_cell * d_new_cell;
auto gates = gate_weights.chunk(3, /*dim=*/1);
d_input_gate *= d_sigmoid(gates[0]);
d_output_gate *= d_sigmoid(gates[1]);
d_candidate_cell *= d_elu(gates[2]);
auto d_gates =
torch::cat({d_input_gate, d_output_gate, d_candidate_cell}, /*dim=*/1);
auto d_weights = d_gates.t().mm(X);
auto d_bias = d_gates.sum(/*dim=*/0, /*keepdim=*/true);
auto d_X = d_gates.mm(weights);
const auto state_size = grad_h.size(1);
auto d_old_h = d_X.slice(/*dim=*/1, 0, state_size);
auto d_input = d_X.slice(/*dim=*/1, state_size);
return {d_old_h, d_input, d_weights, d_bias, d_old_cell};
}
绑定到 Python¶
一旦您在 C++ 和 ATen 中编写了您的操作,您就可以使用 pybind11 以非常简单的方式将您的 C++ 函数或类绑定到 Python 中。您关于 PyTorch C++ 扩展的这部分的问题或问题将在很大程度上通过 pybind11 文档 解决。
对于我们的扩展,必要的绑定代码仅跨越四行
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
m.def("forward", &lltm_forward, "LLTM forward");
m.def("backward", &lltm_backward, "LLTM backward");
}
这里需要注意的一点是宏 TORCH_EXTENSION_NAME
。 torch 扩展构建会将其定义为您在 setup.py
脚本中为扩展指定的名称。在这种情况下,TORCH_EXTENSION_NAME
的值将为“lltm_cpp”。这是为了避免必须在两个地方(构建脚本和您的 C++ 代码)维护扩展的名称,因为两者之间的不匹配可能会导致令人讨厌且难以跟踪的问题。
使用您的扩展¶
我们现在已准备好在 PyTorch 中导入我们的扩展。此时,您的目录结构可能如下所示
pytorch/
lltm-extension/
lltm.cpp
setup.py
现在,运行 python setup.py install
以构建和安装您的扩展。这应该看起来像这样
running install
running bdist_egg
running egg_info
creating lltm_cpp.egg-info
writing lltm_cpp.egg-info/PKG-INFO
writing dependency_links to lltm_cpp.egg-info/dependency_links.txt
writing top-level names to lltm_cpp.egg-info/top_level.txt
writing manifest file 'lltm_cpp.egg-info/SOURCES.txt'
reading manifest file 'lltm_cpp.egg-info/SOURCES.txt'
writing manifest file 'lltm_cpp.egg-info/SOURCES.txt'
installing library code to build/bdist.linux-x86_64/egg
running install_lib
running build_ext
building 'lltm_cpp' extension
creating build
creating build/temp.linux-x86_64-3.7
gcc -pthread -B ~/local/miniconda/compiler_compat -Wl,--sysroot=/ -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -Wstrict-prototypes -fPIC -I~/local/miniconda/lib/python3.7/site-packages/torch/include -I~/local/miniconda/lib/python3.7/site-packages/torch/include/torch/csrc/api/include -I~/local/miniconda/lib/python3.7/site-packages/torch/include/TH -I~/local/miniconda/lib/python3.7/site-packages/torch/include/THC -I~/local/miniconda/include/python3.7m -c lltm.cpp -o build/temp.linux-x86_64-3.7/lltm.o -DTORCH_API_INCLUDE_EXTENSION_H -DTORCH_EXTENSION_NAME=lltm_cpp -D_GLIBCXX_USE_CXX11_ABI=1 -std=c++11
cc1plus: warning: command line option ‘-Wstrict-prototypes’ is valid for C/ObjC but not for C++
creating build/lib.linux-x86_64-3.7
g++ -pthread -shared -B ~/local/miniconda/compiler_compat -L~/local/miniconda/lib -Wl,-rpath=~/local/miniconda/lib -Wl,--no-as-needed -Wl,--sysroot=/ build/temp.linux-x86_64-3.7/lltm.o -o build/lib.linux-x86_64-3.7/lltm_cpp.cpython-37m-x86_64-linux-gnu.so
creating build/bdist.linux-x86_64
creating build/bdist.linux-x86_64/egg
copying build/lib.linux-x86_64-3.7/lltm_cpp.cpython-37m-x86_64-linux-gnu.so -> build/bdist.linux-x86_64/egg
creating stub loader for lltm_cpp.cpython-37m-x86_64-linux-gnu.so
byte-compiling build/bdist.linux-x86_64/egg/lltm_cpp.py to lltm_cpp.cpython-37.pyc
creating build/bdist.linux-x86_64/egg/EGG-INFO
copying lltm_cpp.egg-info/PKG-INFO -> build/bdist.linux-x86_64/egg/EGG-INFO
copying lltm_cpp.egg-info/SOURCES.txt -> build/bdist.linux-x86_64/egg/EGG-INFO
copying lltm_cpp.egg-info/dependency_links.txt -> build/bdist.linux-x86_64/egg/EGG-INFO
copying lltm_cpp.egg-info/top_level.txt -> build/bdist.linux-x86_64/egg/EGG-INFO
writing build/bdist.linux-x86_64/egg/EGG-INFO/native_libs.txt
zip_safe flag not set; analyzing archive contents...
__pycache__.lltm_cpp.cpython-37: module references __file__
creating 'dist/lltm_cpp-0.0.0-py3.7-linux-x86_64.egg' and adding 'build/bdist.linux-x86_64/egg' to it
removing 'build/bdist.linux-x86_64/egg' (and everything under it)
Processing lltm_cpp-0.0.0-py3.7-linux-x86_64.egg
removing '~/local/miniconda/lib/python3.7/site-packages/lltm_cpp-0.0.0-py3.7-linux-x86_64.egg' (and everything under it)
creating ~/local/miniconda/lib/python3.7/site-packages/lltm_cpp-0.0.0-py3.7-linux-x86_64.egg
Extracting lltm_cpp-0.0.0-py3.7-linux-x86_64.egg to ~/local/miniconda/lib/python3.7/site-packages
lltm-cpp 0.0.0 is already the active version in easy-install.pth
Installed ~/local/miniconda/lib/python3.7/site-packages/lltm_cpp-0.0.0-py3.7-linux-x86_64.egg
Processing dependencies for lltm-cpp==0.0.0
Finished processing dependencies for lltm-cpp==0.0.0
关于编译器的小提示:由于 ABI 版本控制问题,您用于构建 C++ 扩展的编译器必须与构建 PyTorch 的编译器 ABI 兼容。在实践中,这意味着您必须在 Linux 上使用 GCC 版本 4.9 及更高版本。对于 Ubuntu 16.04 和其他较新的 Linux 发行版,这应该是默认编译器。在 MacOS 上,您必须使用 clang(它没有任何 ABI 版本控制问题)。在最坏的情况下,您可以使用您的编译器从源代码构建 PyTorch,然后使用相同的编译器构建扩展。
构建扩展后,您可以简单地在 Python 中导入它,使用您在 setup.py
脚本中指定的名称。只需确保首先 import torch
,因为这将解析动态链接器必须看到的一些符号
In [1]: import torch
In [2]: import lltm_cpp
In [3]: lltm_cpp.forward
Out[3]: <function lltm.PyCapsule.forward>
如果我们对函数或模块调用 help()
,我们可以看到它的签名与我们的 C++ 代码匹配
In[4] help(lltm_cpp.forward)
forward(...) method of builtins.PyCapsule instance
forward(arg0: torch::Tensor, arg1: torch::Tensor, arg2: torch::Tensor, arg3: torch::Tensor, arg4: torch::Tensor) -> List[torch::Tensor]
LLTM forward
由于我们现在能够从 Python 调用我们的 C++ 函数,我们可以使用 torch.autograd.Function
和 torch.nn.Module
包装它们,使它们成为 PyTorch 的一等公民
import math
import torch
# Our module!
import lltm_cpp
class LLTMFunction(torch.autograd.Function):
@staticmethod
def forward(ctx, input, weights, bias, old_h, old_cell):
outputs = lltm_cpp.forward(input, weights, bias, old_h, old_cell)
new_h, new_cell = outputs[:2]
variables = outputs[1:] + [weights]
ctx.save_for_backward(*variables)
return new_h, new_cell
@staticmethod
def backward(ctx, grad_h, grad_cell):
outputs = lltm_cpp.backward(
grad_h.contiguous(), grad_cell.contiguous(), *ctx.saved_tensors)
d_old_h, d_input, d_weights, d_bias, d_old_cell = outputs
return d_input, d_weights, d_bias, d_old_h, d_old_cell
class LLTM(torch.nn.Module):
def __init__(self, input_features, state_size):
super(LLTM, self).__init__()
self.input_features = input_features
self.state_size = state_size
self.weights = torch.nn.Parameter(
torch.empty(3 * state_size, input_features + state_size))
self.bias = torch.nn.Parameter(torch.empty(3 * state_size))
self.reset_parameters()
def reset_parameters(self):
stdv = 1.0 / math.sqrt(self.state_size)
for weight in self.parameters():
weight.data.uniform_(-stdv, +stdv)
def forward(self, input, state):
return LLTMFunction.apply(input, self.weights, self.bias, *state)
性能比较¶
现在我们能够从 PyTorch 使用和调用我们的 C++ 代码,我们可以运行一个小基准测试,看看我们从用 C++ 重写我们的 op 中获得了多少性能提升。我们将多次正向和反向运行 LLTM 并测量持续时间
import time
import torch
batch_size = 16
input_features = 32
state_size = 128
X = torch.randn(batch_size, input_features)
h = torch.randn(batch_size, state_size)
C = torch.randn(batch_size, state_size)
rnn = LLTM(input_features, state_size)
forward = 0
backward = 0
for _ in range(100000):
start = time.time()
new_h, new_C = rnn(X, (h, C))
forward += time.time() - start
start = time.time()
(new_h.sum() + new_C.sum()).backward()
backward += time.time() - start
print('Forward: {:.3f} s | Backward {:.3f} s'.format(forward, backward))
如果我们使用我们在本文开头用纯 Python 编写的原始 LLTM 运行此代码,我们会得到以下数字(在我的机器上)
Forward: 506.480 us | Backward 444.694 us
以及使用我们新的 C++ 版本
Forward: 349.335 us | Backward 443.523 us
我们已经可以看到前向函数显着的加速(超过 30%)。对于反向函数,可以看到加速,但不是主要的。我上面写的反向传递没有经过特别优化,肯定可以改进。此外,PyTorch 的自动微分引擎可以自动并行化计算图,可能会使用更高效的操作流程,并且也是用 C++ 实现的,因此预计会很快。尽管如此,这是一个良好的开端。
GPU 设备上的性能¶
关于 PyTorch 的 ATen 后端的一个奇妙之处在于,它可以抽象您正在运行的计算设备。这意味着我们为 CPU 编写的相同代码也可以在 GPU 上运行,并且各个操作将相应地调度到 GPU 优化的实现。对于某些操作,如矩阵乘法(如 mm
或 addmm
),这是一个很大的胜利。让我们看看从使用 CUDA 张量运行我们的 C++ 代码中获得了多少性能提升。我们的实现不需要任何更改,我们只需要从 Python 将我们的张量放入 GPU 内存中,方法是在创建时添加 device=cuda_device
参数或在创建后使用 .to(cuda_device)
import torch
assert torch.cuda.is_available()
cuda_device = torch.device("cuda") # device object representing GPU
batch_size = 16
input_features = 32
state_size = 128
# Note the device=cuda_device arguments here
X = torch.randn(batch_size, input_features, device=cuda_device)
h = torch.randn(batch_size, state_size, device=cuda_device)
C = torch.randn(batch_size, state_size, device=cuda_device)
rnn = LLTM(input_features, state_size).to(cuda_device)
forward = 0
backward = 0
for _ in range(100000):
start = time.time()
new_h, new_C = rnn(X, (h, C))
torch.cuda.synchronize()
forward += time.time() - start
start = time.time()
(new_h.sum() + new_C.sum()).backward()
torch.cuda.synchronize()
backward += time.time() - start
print('Forward: {:.3f} us | Backward {:.3f} us'.format(forward * 1e6/1e5, backward * 1e6/1e5))
再次将我们的纯 PyTorch 代码与我们的 C++ 版本进行比较,现在两者都在 CUDA 设备上运行,我们再次看到了性能提升。对于 Python/PyTorch
Forward: 187.719 us | Backward 410.815 us
和 C++/ATen
Forward: 149.802 us | Backward 393.458 us
与非 CUDA 代码相比,这是一个巨大的整体加速。但是,通过编写自定义 CUDA 内核,我们可以从我们的 C++ 代码中获得更多性能,我们很快将深入探讨。在此之前,让我们讨论另一种构建 C++ 扩展的方法。
JIT 编译扩展¶
之前,我提到构建 C++ 扩展有两种方法:使用 setuptools
或即时 (JIT)。在介绍了前者之后,让我们详细说明后者。 JIT 编译机制为您提供了一种通过调用 PyTorch API 中称为 torch.utils.cpp_extension.load()
的简单函数来动态编译和加载扩展的方法。对于 LLTM,这将看起来像这样简单
from torch.utils.cpp_extension import load
lltm_cpp = load(name="lltm_cpp", sources=["lltm.cpp"])
在这里,我们为该函数提供与 setuptools
相同的信息。在后台,这将执行以下操作
创建一个临时目录
/tmp/torch_extensions/lltm
,将 Ninja 构建文件发放到该临时目录中,
将您的源文件编译为共享库,
将此共享库作为 Python 模块导入。
实际上,如果您将 verbose=True
传递给 cpp_extension.load()
,您将收到有关该过程的通知
Using /tmp/torch_extensions as PyTorch extensions root...
Emitting ninja build file /tmp/torch_extensions/lltm_cpp/build.ninja...
Building extension module lltm_cpp...
Loading extension module lltm_cpp...
生成的 Python 模块将与 setuptools 生成的模块完全相同,但消除了必须维护单独的 setup.py
构建文件的要求。如果您的设置更复杂,并且您确实需要 setuptools
的全部功能,您可以编写自己的 setup.py
– 但在许多情况下,这种 JIT 技术就足够了。您第一次运行此行时,将花费一些时间,因为扩展在后台编译。由于我们使用 Ninja 构建系统来构建您的源,因此重新编译是增量的,因此当您第二次运行 Python 模块时,重新加载扩展速度很快,开销也很低(如果您没有更改扩展的源文件)。
编写混合 C++/CUDA 扩展¶
为了真正将我们的实现提升到一个新的水平,我们可以手动编写带有自定义 CUDA 内核的前向和反向传递的部分。对于 LLTM,这有望特别有效,因为顺序中有大量逐点操作,所有这些操作都可以在单个 CUDA 内核中融合和并行化。让我们看看我们如何编写这样的 CUDA 内核并使用这种扩展机制将其与 PyTorch 集成。
编写 CUDA 扩展的通用策略是首先编写一个 C++ 文件,该文件定义将从 Python 调用的函数,并使用 pybind11 将这些函数绑定到 Python。此外,此文件还将声明在 CUDA (.cu
) 文件中定义的函数。然后,C++ 函数将进行一些检查,并最终将其调用转发到 CUDA 函数。在 CUDA 文件中,我们编写实际的 CUDA 内核。cpp_extension
包将负责使用 C++ 编译器(如 gcc
)编译 C++ 源代码,并使用 NVIDIA 的 nvcc
编译器编译 CUDA 源代码。这确保了每个编译器都处理它最擅长编译的文件。最终,它们将被链接到一个共享库中,我们可以从 Python 代码中使用该库。
我们将从 C++ 文件开始,例如,我们将其命名为 lltm_cuda.cpp
#include <torch/extension.h>
#include <vector>
// CUDA forward declarations
std::vector<torch::Tensor> lltm_cuda_forward(
torch::Tensor input,
torch::Tensor weights,
torch::Tensor bias,
torch::Tensor old_h,
torch::Tensor old_cell);
std::vector<torch::Tensor> lltm_cuda_backward(
torch::Tensor grad_h,
torch::Tensor grad_cell,
torch::Tensor new_cell,
torch::Tensor input_gate,
torch::Tensor output_gate,
torch::Tensor candidate_cell,
torch::Tensor X,
torch::Tensor gate_weights,
torch::Tensor weights);
// C++ interface
#define CHECK_CUDA(x) TORCH_CHECK(x.device().is_cuda(), #x " must be a CUDA tensor")
#define CHECK_CONTIGUOUS(x) TORCH_CHECK(x.is_contiguous(), #x " must be contiguous")
#define CHECK_INPUT(x) CHECK_CUDA(x); CHECK_CONTIGUOUS(x)
std::vector<torch::Tensor> lltm_forward(
torch::Tensor input,
torch::Tensor weights,
torch::Tensor bias,
torch::Tensor old_h,
torch::Tensor old_cell) {
CHECK_INPUT(input);
CHECK_INPUT(weights);
CHECK_INPUT(bias);
CHECK_INPUT(old_h);
CHECK_INPUT(old_cell);
return lltm_cuda_forward(input, weights, bias, old_h, old_cell);
}
std::vector<torch::Tensor> lltm_backward(
torch::Tensor grad_h,
torch::Tensor grad_cell,
torch::Tensor new_cell,
torch::Tensor input_gate,
torch::Tensor output_gate,
torch::Tensor candidate_cell,
torch::Tensor X,
torch::Tensor gate_weights,
torch::Tensor weights) {
CHECK_INPUT(grad_h);
CHECK_INPUT(grad_cell);
CHECK_INPUT(input_gate);
CHECK_INPUT(output_gate);
CHECK_INPUT(candidate_cell);
CHECK_INPUT(X);
CHECK_INPUT(gate_weights);
CHECK_INPUT(weights);
return lltm_cuda_backward(
grad_h,
grad_cell,
new_cell,
input_gate,
output_gate,
candidate_cell,
X,
gate_weights,
weights);
}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
m.def("forward", &lltm_forward, "LLTM forward (CUDA)");
m.def("backward", &lltm_backward, "LLTM backward (CUDA)");
}
正如您所看到的,它在很大程度上是样板代码,检查和转发到我们将在 CUDA 文件中定义的函数。我们将此文件命名为 lltm_cuda_kernel.cu
(请注意 .cu
扩展名!)。NVCC 可以合理地编译 C++11,因此我们仍然可以使用 ATen 和 C++ 标准库(但不能使用 torch.h
)。请注意,setuptools
无法处理名称相同但扩展名不同的文件,因此如果您使用 setup.py
方法而不是 JIT 方法,则必须为 CUDA 文件提供与 C++ 文件不同的名称(对于 JIT 方法,lltm.cpp
和 lltm.cu
可以正常工作)。让我们简要了解一下此文件的外观
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
#include <vector>
template <typename scalar_t>
__device__ __forceinline__ scalar_t sigmoid(scalar_t z) {
return 1.0 / (1.0 + exp(-z));
}
在这里,我们看到了我刚刚描述的头文件,以及我们正在使用 CUDA 特定的声明,例如 __device__
和 __forceinline__
以及函数,例如 exp
。让我们继续介绍一些我们将需要的更多辅助函数
template <typename scalar_t>
__device__ __forceinline__ scalar_t d_sigmoid(scalar_t z) {
const auto s = sigmoid(z);
return (1.0 - s) * s;
}
template <typename scalar_t>
__device__ __forceinline__ scalar_t d_tanh(scalar_t z) {
const auto t = tanh(z);
return 1 - (t * t);
}
template <typename scalar_t>
__device__ __forceinline__ scalar_t elu(scalar_t z, scalar_t alpha = 1.0) {
return fmax(0.0, z) + fmin(0.0, alpha * (exp(z) - 1.0));
}
template <typename scalar_t>
__device__ __forceinline__ scalar_t d_elu(scalar_t z, scalar_t alpha = 1.0) {
const auto e = exp(z);
const auto d_relu = z < 0.0 ? 0.0 : 1.0;
return d_relu + (((alpha * (e - 1.0)) < 0.0) ? (alpha * e) : 0.0);
}
现在要实际实现一个函数,我们再次需要两件事:一个执行我们不希望手动显式编写的操作并调用 CUDA 内核的函数,然后是用于我们想要加速的部分的实际 CUDA 内核。对于前向传播,第一个函数应如下所示
std::vector<torch::Tensor> lltm_cuda_forward(
torch::Tensor input,
torch::Tensor weights,
torch::Tensor bias,
torch::Tensor old_h,
torch::Tensor old_cell) {
auto X = torch::cat({old_h, input}, /*dim=*/1);
auto gates = torch::addmm(bias, X, weights.transpose(0, 1));
const auto batch_size = old_cell.size(0);
const auto state_size = old_cell.size(1);
auto new_h = torch::zeros_like(old_cell);
auto new_cell = torch::zeros_like(old_cell);
auto input_gate = torch::zeros_like(old_cell);
auto output_gate = torch::zeros_like(old_cell);
auto candidate_cell = torch::zeros_like(old_cell);
const int threads = 1024;
const dim3 blocks((state_size + threads - 1) / threads, batch_size);
AT_DISPATCH_FLOATING_TYPES(gates.type(), "lltm_forward_cuda", ([&] {
lltm_cuda_forward_kernel<scalar_t><<<blocks, threads>>>(
gates.data<scalar_t>(),
old_cell.data<scalar_t>(),
new_h.data<scalar_t>(),
new_cell.data<scalar_t>(),
input_gate.data<scalar_t>(),
output_gate.data<scalar_t>(),
candidate_cell.data<scalar_t>(),
state_size);
}));
return {new_h, new_cell, input_gate, output_gate, candidate_cell, X, gates};
}
这里的主要关注点是 AT_DISPATCH_FLOATING_TYPES
宏和内核启动(由 <<<...>>>
指示)。虽然 ATen 抽象了我们处理的张量的设备和数据类型,但张量在运行时仍将由具体类型在具体设备上的内存支持。因此,我们需要一种方法在运行时确定张量的类型,然后有选择地调用具有相应正确类型签名的函数。手动完成,这在概念上看起来像这样
switch (tensor.type().scalarType()) {
case torch::ScalarType::Double:
return function<double>(tensor.data<double>());
case torch::ScalarType::Float:
return function<float>(tensor.data<float>());
...
}
AT_DISPATCH_FLOATING_TYPES
的目的是为我们处理这种调度。它接受一个类型(在我们的例子中是 gates.type()
),一个名称(用于错误消息)和一个 lambda 函数。在此 lambda 函数内部,类型别名 scalar_t
可用,并且被定义为张量在该上下文中运行时实际的类型。因此,如果我们有一个模板函数(我们的 CUDA 内核将是),我们可以使用此 scalar_t
别名实例化它,并且将调用正确的函数。在这种情况下,我们还希望检索张量的数据指针作为该 scalar_t
类型的指针。如果您想调度所有类型,而不仅仅是浮点类型(Float
和 Double
),则可以使用 AT_DISPATCH_ALL_TYPES
。
请注意,我们使用普通的 ATen 执行一些操作。这些操作仍将在 GPU 上运行,但使用 ATen 的默认实现。这是有道理的,因为 ATen 将对矩阵乘法(例如 addmm
)或卷积等操作使用高度优化的例程,这将更难实现和改进我们自己。
至于内核启动本身,我们在这里指定每个 CUDA 块将有 1024 个线程,并且整个 GPU 网格被分成尽可能多的 1 x 1024
线程块,以填充我们的矩阵,每个组件一个线程。例如,如果我们的状态大小为 2048,批量大小为 4,我们将总共启动 4 x 2 = 8
个块,每个块有 1024 个线程。如果您以前从未听说过 CUDA “块”或“网格”,那么关于 CUDA 的 入门读物 可能会有所帮助。
实际的 CUDA 内核相当简单(如果您以前编写过 GPU 程序)
template <typename scalar_t>
__global__ void lltm_cuda_forward_kernel(
const scalar_t* __restrict__ gates,
const scalar_t* __restrict__ old_cell,
scalar_t* __restrict__ new_h,
scalar_t* __restrict__ new_cell,
scalar_t* __restrict__ input_gate,
scalar_t* __restrict__ output_gate,
scalar_t* __restrict__ candidate_cell,
size_t state_size) {
const int column = blockIdx.x * blockDim.x + threadIdx.x;
const int index = blockIdx.y * state_size + column;
const int gates_row = blockIdx.y * (state_size * 3);
if (column < state_size) {
input_gate[index] = sigmoid(gates[gates_row + column]);
output_gate[index] = sigmoid(gates[gates_row + state_size + column]);
candidate_cell[index] = elu(gates[gates_row + 2 * state_size + column]);
new_cell[index] =
old_cell[index] + candidate_cell[index] * input_gate[index];
new_h[index] = tanh(new_cell[index]) * output_gate[index];
}
}
这里最有趣的是,我们能够为门矩阵中的每个单独组件完全并行地计算所有这些逐点运算。如果您想象必须使用巨大的 for
循环串行遍历一百万个元素来执行此操作,您就会明白为什么这会快得多。
使用访问器¶
您可以在 CUDA 内核中看到,我们直接使用具有正确类型的指针进行操作。实际上,在 CUDA 内核内部直接使用高级类型不可知的张量将非常低效。
但是,这会以易用性和可读性为代价,特别是对于高维数据。在我们的示例中,我们例如知道连续的 gates
张量具有 3 个维度
批次,大小为
batch_size
,步幅为3*state_size
行,大小为
3
,步幅为state_size
索引,大小为
state_size
,步幅为1
那么我们如何在内核内部访问元素 gates[n][row][column]
呢?事实证明,您需要步幅才能使用一些简单的算术运算来访问您的元素。
gates.data<scalar_t>()[n*3*state_size + row*state_size + column]
除了冗长之外,此表达式还需要显式地知道步幅,因此需要在其参数中传递给内核函数。您可以看到,在内核函数接受具有不同大小的多个张量的情况下,您最终会得到一个非常长的参数列表。
幸运的是,ATen 提供了访问器,这些访问器是通过对张量类型和维度数量的单个动态检查创建的。然后,访问器公开了一个 API,用于有效地访问张量元素,而无需转换为单个指针
torch::Tensor foo = torch::rand({12, 12});
// assert foo is 2-dimensional and holds floats.
auto foo_a = foo.accessor<float,2>();
float trace = 0;
for(int i = 0; i < foo_a.size(0); i++) {
// use the accessor foo_a to get tensor data.
trace += foo_a[i][i];
}
访问器对象具有相对高级的接口,具有 .size()
和 .stride()
方法以及多维索引。.accessor<>
接口旨在有效地访问 CPU 张量上的数据。CUDA 张量的等效项是 packed_accessor64<>
和 packed_accessor32<>
,它们生成具有 64 位或 32 位整数索引的 Packed Accessor。
与 Accessor 的根本区别在于,Packed Accessor 将大小和步幅数据复制到其结构内部,而不是指向它。这允许我们将其传递给 CUDA 内核函数并在其中使用其接口。
我们可以设计一个接受 Packed Accessor 而不是指针的函数。
__global__ void lltm_cuda_forward_kernel(
const torch::PackedTensorAccessor32<scalar_t,3,torch::RestrictPtrTraits> gates,
const torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> old_cell,
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> new_h,
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> new_cell,
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> input_gate,
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> output_gate,
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> candidate_cell)
让我们分解此处使用的模板。前两个参数 scalar_t
和 2
与常规 Accessor 相同。参数 torch::RestrictPtrTraits
指示必须使用 __restrict__
关键字。另请注意,我们使用了 PackedAccessor32
变体,它将大小和步幅存储在 int32_t
中。这很重要,因为使用 64 位变体 (PackedAccessor64
) 可能会使内核变慢。
函数声明变为
template <typename scalar_t>
__global__ void lltm_cuda_forward_kernel(
const torch::PackedTensorAccessor32<scalar_t,3,torch::RestrictPtrTraits> gates,
const torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> old_cell,
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> new_h,
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> new_cell,
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> input_gate,
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> output_gate,
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> candidate_cell) {
//batch index
const int n = blockIdx.y;
// column index
const int c = blockIdx.x * blockDim.x + threadIdx.x;
if (c < gates.size(2)){
input_gate[n][c] = sigmoid(gates[n][0][c]);
output_gate[n][c] = sigmoid(gates[n][1][c]);
candidate_cell[n][c] = elu(gates[n][2][c]);
new_cell[n][c] =
old_cell[n][c] + candidate_cell[n][c] * input_gate[n][c];
new_h[n][c] = tanh(new_cell[n][c]) * output_gate[n][c];
}
}
实现更具可读性!然后通过使用主机函数中的 .packed_accessor32<>
方法创建 Packed Accessor 来调用此函数。
std::vector<torch::Tensor> lltm_cuda_forward(
torch::Tensor input,
torch::Tensor weights,
torch::Tensor bias,
torch::Tensor old_h,
torch::Tensor old_cell) {
auto X = torch::cat({old_h, input}, /*dim=*/1);
auto gate_weights = torch::addmm(bias, X, weights.transpose(0, 1));
const auto batch_size = old_cell.size(0);
const auto state_size = old_cell.size(1);
auto gates = gate_weights.reshape({batch_size, 3, state_size});
auto new_h = torch::zeros_like(old_cell);
auto new_cell = torch::zeros_like(old_cell);
auto input_gate = torch::zeros_like(old_cell);
auto output_gate = torch::zeros_like(old_cell);
auto candidate_cell = torch::zeros_like(old_cell);
const int threads = 1024;
const dim3 blocks((state_size + threads - 1) / threads, batch_size);
AT_DISPATCH_FLOATING_TYPES(gates.type(), "lltm_forward_cuda", ([&] {
lltm_cuda_forward_kernel<scalar_t><<<blocks, threads>>>(
gates.packed_accessor32<scalar_t,3,torch::RestrictPtrTraits>(),
old_cell.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
new_h.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
new_cell.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
input_gate.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
output_gate.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
candidate_cell.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>());
}));
return {new_h, new_cell, input_gate, output_gate, candidate_cell, X, gates};
}
后向传播遵循大致相同的模式,我将不再详细说明
template <typename scalar_t>
__global__ void lltm_cuda_backward_kernel(
torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> d_old_cell,
torch::PackedTensorAccessor32<scalar_t,3,torch::RestrictPtrTraits> d_gates,
const torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> grad_h,
const torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> grad_cell,
const torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> new_cell,
const torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> input_gate,
const torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> output_gate,
const torch::PackedTensorAccessor32<scalar_t,2,torch::RestrictPtrTraits> candidate_cell,
const torch::PackedTensorAccessor32<scalar_t,3,torch::RestrictPtrTraits> gate_weights) {
//batch index
const int n = blockIdx.y;
// column index
const int c = blockIdx.x * blockDim.x + threadIdx.x;
if (c < d_gates.size(2)){
const auto d_output_gate = tanh(new_cell[n][c]) * grad_h[n][c];
const auto d_tanh_new_cell = output_gate[n][c] * grad_h[n][c];
const auto d_new_cell =
d_tanh(new_cell[n][c]) * d_tanh_new_cell + grad_cell[n][c];
d_old_cell[n][c] = d_new_cell;
const auto d_candidate_cell = input_gate[n][c] * d_new_cell;
const auto d_input_gate = candidate_cell[n][c] * d_new_cell;
d_gates[n][0][c] =
d_input_gate * d_sigmoid(gate_weights[n][0][c]);
d_gates[n][1][c] =
d_output_gate * d_sigmoid(gate_weights[n][1][c]);
d_gates[n][2][c] =
d_candidate_cell * d_elu(gate_weights[n][2][c]);
}
}
std::vector<torch::Tensor> lltm_cuda_backward(
torch::Tensor grad_h,
torch::Tensor grad_cell,
torch::Tensor new_cell,
torch::Tensor input_gate,
torch::Tensor output_gate,
torch::Tensor candidate_cell,
torch::Tensor X,
torch::Tensor gates,
torch::Tensor weights) {
auto d_old_cell = torch::zeros_like(new_cell);
auto d_gates = torch::zeros_like(gates);
const auto batch_size = new_cell.size(0);
const auto state_size = new_cell.size(1);
const int threads = 1024;
const dim3 blocks((state_size + threads - 1) / threads, batch_size);
AT_DISPATCH_FLOATING_TYPES(X.type(), "lltm_backward_cuda", ([&] {
lltm_cuda_backward_kernel<scalar_t><<<blocks, threads>>>(
d_old_cell.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
d_gates.packed_accessor32<scalar_t,3,torch::RestrictPtrTraits>(),
grad_h.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
grad_cell.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
new_cell.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
input_gate.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
output_gate.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
candidate_cell.packed_accessor32<scalar_t,2,torch::RestrictPtrTraits>(),
gates.packed_accessor32<scalar_t,3,torch::RestrictPtrTraits>());
}));
auto d_gate_weights = d_gates.reshape({batch_size, 3*state_size});
auto d_weights = d_gate_weights.t().mm(X);
auto d_bias = d_gate_weights.sum(/*dim=*/0, /*keepdim=*/true);
auto d_X = d_gate_weights.mm(weights);
auto d_old_h = d_X.slice(/*dim=*/1, 0, state_size);
auto d_input = d_X.slice(/*dim=*/1, state_size);
return {d_old_h, d_input, d_weights, d_bias, d_old_cell, d_gates};
}
将 C++/CUDA 操作与 PyTorch 集成¶
将我们的 CUDA 启用操作与 PyTorch 集成再次非常简单。如果您想编写 setup.py
脚本,它可以如下所示
from setuptools import setup
from torch.utils.cpp_extension import BuildExtension, CUDAExtension
setup(
name='lltm',
ext_modules=[
CUDAExtension('lltm_cuda', [
'lltm_cuda.cpp',
'lltm_cuda_kernel.cu',
])
],
cmdclass={
'build_ext': BuildExtension
})
现在我们使用 CUDAExtension()
而不是 CppExtension()
。我们可以只指定 .cu
文件以及 .cpp
文件 – 该库会为您处理由此带来的所有麻烦。JIT 机制甚至更简单
from torch.utils.cpp_extension import load
lltm = load(name='lltm', sources=['lltm_cuda.cpp', 'lltm_cuda_kernel.cu'])
性能比较¶
我们希望使用 CUDA 并行化和融合我们代码的逐点运算将提高 LLTM 的性能。让我们看看这是否成立。我们可以运行我之前列出的代码来运行基准测试。我们之前最快的版本是基于 CUDA 的 C++ 代码
Forward: 149.802 us | Backward 393.458 us
现在使用我们的自定义 CUDA 内核
Forward: 129.431 us | Backward 304.641 us
性能进一步提升!