自定义 C++ 与 CUDA 拓展

译者:P3n9W31

Author: Peter Goldsborough

PyTorch 提供了大量与神经网络,任意张量代数(arbitrary tensor algebra),数据处理(data wrangling)和其他目的相关的操作。然而,你可能发现你还是会需要一些更加自定义的操作。例如,你有时可能希望使用一个你在某篇论文中找到的一个新型的激活函数,或者是实现一个为了你的研究所开发的新操作。

在 PyTorch 中集成这种自定义操作的最简单方法是通过 Python 语言对FunctionModule进行扩写,正如在 这里所描述的那样。这种方式能让你充分的发挥自动微分(automatic differentiation)(让你不用去编写一些衍生的函数)与 Python 语言的常规情况下的表现力(usual expressiveness)的能力。但是有时候,可能在 C++ 语言中能够更好地实现你的一些操作。例如,你的代码可能因为被非常频繁的使用而需要 十分 快速,或者是即使调用的次数很少也会带来不小的性能负担。另一个原因是你的代码可能是建立在 C 或 C++ 语言之上的,或者你的代码需要与 C 或 C++ 语言进行交互与对接。为了解决上述的这些情况,PyTorch 提供了一种简单的用于编写自定义 C++ 扩展 的方法。

C++ 拓展是我们开发的一种能够让用户(你)自行创建一些 所含资源之外 的操作的机制,例如,与 PyTorch 的后端分离开来。这种方法与 PyTorch 原生操作的实现方式是 不同的 。C++ 扩展旨在为你提供与 PyTorch 后端集成操作相关的大部分样板(boilerplate),同时为基于 PyTorch 的项目提供高度灵活性。然而,一旦你将你的操作定义为了 C++ 拓展,将其转换为原生 PyTorch 函数就主要是代码组织的问题了,如果你决定在上游提供操作,则可以解决这个问题。

动机与例子

本篇文章的剩余部分将介绍一个编写和使用 C++(以及 CUDA)拓展的实例。如果你现在正在被一直催着或是在今天之前没有把该操作完成你就会被解雇的话,你可以跳过这一章节,直接去下一节的实施细节部分查看。

假设你已经找到了一种新型的循环(recurrent)的单元,它与现有技术相比具有优越的性能。该循环单元类似于 LSTM,但不同之处在于它缺少了 遗忘门 并使用 指数线性单元 (ELU)作为其内部激活功能。因为这个单元永远都不会忘记,所以我们叫它 LLTM,或是 长长期记忆 (Long-Long-Term-Memory)单元。

在 LLTMs 中的这两个与普通的 LSTMs 的不同点是十分重要的,以至于我们不能通过配置 PyTorch 中的 LSTMCell 来达到我们的目标。所以我们将只能创建一个自定义模块。第一个也是最简单的方法 - 可能在所有情况下都是良好的第一步——是使用 Python 在纯 PyTorch 中实现我们所需的功能。为此,我们需要继承 torch.nn.Module 并实现 LLTM 的正向传递。 这看起来就像这样:

  1. class LLTM(torch.nn.Module):
  2. def __init__(self, input_features, state_size):
  3. super(LLTM, self).__init__()
  4. self.input_features = input_features
  5. self.state_size = state_size
  6. # 3 * state_size for input gate, output gate and candidate cell gate.
  7. # input_features + state_size because we will multiply with [input, h].
  8. self.weights = torch.nn.Parameter(
  9. torch.empty(3 * state_size, input_features + state_size))
  10. self.bias = torch.nn.Parameter(torch.empty(3 * state_size))
  11. self.reset_parameters()
  12. def reset_parameters(self):
  13. stdv = 1.0 / math.sqrt(self.state_size)
  14. for weight in self.parameters():
  15. weight.data.uniform_(-stdv, +stdv)
  16. def forward(self, input, state):
  17. old_h, old_cell = state
  18. X = torch.cat([old_h, input], dim=1)
  19. # Compute the input, output and candidate cell gates with one MM.
  20. gate_weights = F.linear(X, self.weights, self.bias)
  21. # Split the combined gate weight matrix into its components.
  22. gates = gate_weights.chunk(3, dim=1)
  23. input_gate = F.sigmoid(gates[0])
  24. output_gate = F.sigmoid(gates[1])
  25. # Here we use an ELU instead of the usual tanh.
  26. candidate_cell = F.elu(gates[2])
  27. # Compute the new cell state.
  28. new_cell = old_cell + candidate_cell * input_gate
  29. # Compute the new hidden state and output.
  30. new_h = F.tanh(new_cell) * output_gate
  31. return new_h, new_cell

我们可以按预期使用它:

  1. import torch
  2. X = torch.randn(batch_size, input_features)
  3. h = torch.randn(batch_size, state_size)
  4. C = torch.randn(batch_size, state_size)
  5. rnn = LLTM(input_features, state_size)
  6. new_h, new_C = rnn(X, (h, C))

当然,如果可能的话,你应该使用这种方法来扩展 PyTorch。由于 PyTorch 对 CPU GPU 的操作实施了高度优化,由 NVIDIA cuDNNIntel 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 而言,它看起来就像下面这样简单:

  1. from setuptools import setup
  2. from torch.utils.cpp_extension import CppExtension, BuildExtension
  3. setup(name='lltm',
  4. ext_modules=[CppExtension('lltm', ['lltm.cpp'])],
  5. cmdclass={'build_ext': BuildExtension})

在这段代码中,CppExtensionsetuptools.Extension的一个便利的包装器(wrapper),它传递正确的包含路径并将扩展语言设置为 C++。 等效的普通setuptools代码像下面这样简单:

  1. setuptools.Extension(
  2. name='lltm',
  3. sources=['lltm.cpp'],
  4. include_dirs=torch.utils.cpp_extension.include_paths(),
  5. language='c++')

BuildExtension执行许多必需的配置步骤和检查,并在混合 C++/CUDA 扩展的情况下管理混合编译。 这就是我们现在真正需要了解的关于构建 C++ 扩展的所有内容!现在让我们来看看我们的 C++ 扩展的实现,它扩展到了lltm.cpp中。

编写 C++ 操作

让我们开始用 C++ 实现 LLTM!我们向后传递所需的一个函数是 sigmoid 的导数。这是一段足够小的代码,用于讨论编写 C++ 扩展时可用的整体环境:

  1. #include <torch/torch.h>
  2. #include <iostream>
  3. at::Tensor d_sigmoid(at::Tensor z) {
  4. auto s = at::sigmoid(z);
  5. return (1 - s) * s;
  6. }

torch / torch.h是一站式(one-stop)头文件,包含编写 C++ 扩展所需的所有 PyTorch 位。 这包括:

  • ATen 库,我们主要的张量计算接口
  • pybind11,我们为 C++ 代码创建 Python 绑定的方法
  • 管理 ATen 和 pybind11 之间交互细节的头文件。

d_sigmoid()的实现显示了如何使用 ATen API。PyTorch 的张量和变量接口是从 ATen 库自动生成的,因此我们可以或多或少地将我们的 Python 语言实现1:1转换为 C++ 语言实现。 我们所有计算的主要数据类型都是at::Tensor。可以在此处查看其完整的 API。另请注意,我们可以引用<iostream>或任何其他 C 或 C++ 头文件——我们可以使用 C++ 11 的全部功能。

前向传播

接下来,我们可以将整个前向传播部分移植为 C++ 代码:

  1. #include <vector>
  2. std::vector<at::Tensor> lltm_forward(
  3. at::Tensor input,
  4. at::Tensor weights,
  5. at::Tensor bias,
  6. at::Tensor old_h,
  7. at::Tensor old_cell) {
  8. auto X = at::cat({old_h, input}, /*dim=*/1);
  9. auto gate_weights = at::addmm(bias, X, weights.transpose(0, 1));
  10. auto gates = gate_weights.chunk(3, /*dim=*/1);
  11. auto input_gate = at::sigmoid(gates[0]);
  12. auto output_gate = at::sigmoid(gates[1]);
  13. auto candidate_cell = at::elu(gates[2], /*alpha=*/1.0);
  14. auto new_cell = old_cell + candidate_cell * input_gate;
  15. auto new_h = at::tanh(new_cell) * output_gate;
  16. return {new_h,
  17. new_cell,
  18. input_gate,
  19. output_gate,
  20. candidate_cell,
  21. X,
  22. gate_weights};
  23. }

反向传播

C++ 的扩展 API 目前不为我们提供自动生成反向函数的方法。因此,我们还必须实施 LLTM 的反向传播部分,LLTM 计算相对于正向传播的每个输入的损失的导数。最终,我们将向前和向后函数放入torch.autograd.Function以创建一个漂亮的 Python 绑定。 向后功能稍微复杂一些,所以我们不会深入研究代码(如果你感兴趣,Alex Graves的论文是一个能让你了解跟多信息的好文章):

  1. // tanh'(z) = 1 - tanh^2(z)
  2. at::Tensor d_tanh(at::Tensor z) {
  3. return 1 - z.tanh().pow(2);
  4. }
  5. // elu'(z) = relu'(z) + { alpha * exp(z) if (alpha * (exp(z) - 1)) < 0, else 0}
  6. at::Tensor d_elu(at::Tensor z, at::Scalar alpha = 1.0) {
  7. auto e = z.exp();
  8. auto mask = (alpha * (e - 1)) < 0;
  9. return (z > 0).type_as(z) + mask.type_as(z) * (alpha * e);
  10. }
  11. std::vector<at::Tensor> lltm_backward(
  12. at::Tensor grad_h,
  13. at::Tensor grad_cell,
  14. at::Tensor new_cell,
  15. at::Tensor input_gate,
  16. at::Tensor output_gate,
  17. at::Tensor candidate_cell,
  18. at::Tensor X,
  19. at::Tensor gate_weights,
  20. at::Tensor weights) {
  21. auto d_output_gate = at::tanh(new_cell) * grad_h;
  22. auto d_tanh_new_cell = output_gate * grad_h;
  23. auto d_new_cell = d_tanh(new_cell) * d_tanh_new_cell + grad_cell;
  24. auto d_old_cell = d_new_cell;
  25. auto d_candidate_cell = input_gate * d_new_cell;
  26. auto d_input_gate = candidate_cell * d_new_cell;
  27. auto gates = gate_weights.chunk(3, /*dim=*/1);
  28. d_input_gate *= d_sigmoid(gates[0]);
  29. d_output_gate *= d_sigmoid(gates[1]);
  30. d_candidate_cell *= d_elu(gates[2]);
  31. auto d_gates =
  32. at::cat({d_input_gate, d_output_gate, d_candidate_cell}, /*dim=*/1);
  33. auto d_weights = d_gates.t().mm(X);
  34. auto d_bias = d_gates.sum(/*dim=*/0, /*keepdim=*/true);
  35. auto d_X = d_gates.mm(weights);
  36. const auto state_size = grad_h.size(1);
  37. auto d_old_h = d_X.slice(/*dim=*/1, 0, state_size);
  38. auto d_input = d_X.slice(/*dim=*/1, state_size);
  39. return {d_old_h, d_input, d_weights, d_bias, d_old_cell};
  40. }

与Python绑定

一旦你使用 C++ 和 ATen 编写了操作,就可以使用 pybind11 以非常简单的方式将 C++ 函数或类绑定到 Python 上。关于 PyTorch 的 C++ 扩展的这一部分的问题或疑问将主要通过pybind11文档来解决。

对于我们的扩展,必要的绑定代码只是仅仅四行:

  1. PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
  2. m.def("forward", &lltm_forward, "LLTM forward");
  3. m.def("backward", &lltm_backward, "LLTM backward");
  4. }

有一点需要注意的是宏TORCH_EXTENSION_NAME。torch 的扩展部分将会把它定义为你在setup.py脚本中为扩展名命名的名称。在这种情况下,TORCH_EXTENSION_NAME的值将为“lltm”。这是为了避免必须在两个地方(构建脚本和 C++ 代码中)维护扩展名,因为两者之间的不匹配可能会导致令人讨厌且难以跟踪的问题。

使用你的拓展

我们现在设置为 PyTorch 导入我们的扩展。 此时,你的目录结构可能如下所示:

  1. pytorch/
  2. lltm-extension/
  3. lltm.cpp
  4. setup.py

现在,运行python setup.py install来构建和安装你的扩展。 运行结果应该是这样的:

  1. running install
  2. running bdist_egg
  3. running egg_info
  4. writing lltm.egg-info/PKG-INFO
  5. writing dependency_links to lltm.egg-info/dependency_links.txt
  6. writing top-level names to lltm.egg-info/top_level.txt
  7. reading manifest file 'lltm.egg-info/SOURCES.txt'
  8. writing manifest file 'lltm.egg-info/SOURCES.txt'
  9. installing library code to build/bdist.linux-x86_64/egg
  10. running install_lib
  11. running build_ext
  12. building 'lltm' extension
  13. gcc -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -Wstrict-prototypes -fPIC -I~/local/miniconda/lib/python3.6/site-packages/torch/lib/include -I~/local/miniconda/lib/python3.6/site-packages/torch/lib/include/TH -I~/local/miniconda/lib/python3.6/site-packages/torch/lib/include/THC -I~/local/miniconda/include/python3.6m -c lltm.cpp -o build/temp.linux-x86_64-3.6/lltm.o -DTORCH_EXTENSION_NAME=lltm -std=c++11
  14. cc1plus: warning: command line option ‘-Wstrict-prototypes is valid for C/ObjC but not for C++
  15. 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.6/lltm.o -o build/lib.linux-x86_64-3.6/lltm.cpython-36m-x86_64-linux-gnu.so
  16. creating build/bdist.linux-x86_64/egg
  17. copying build/lib.linux-x86_64-3.6/lltm_cuda.cpython-36m-x86_64-linux-gnu.so -> build/bdist.linux-x86_64/egg
  18. copying build/lib.linux-x86_64-3.6/lltm.cpython-36m-x86_64-linux-gnu.so -> build/bdist.linux-x86_64/egg
  19. creating stub loader for lltm.cpython-36m-x86_64-linux-gnu.so
  20. byte-compiling build/bdist.linux-x86_64/egg/lltm.py to lltm.cpython-36.pyc
  21. creating build/bdist.linux-x86_64/egg/EGG-INFO
  22. copying lltm.egg-info/PKG-INFO -> build/bdist.linux-x86_64/egg/EGG-INFO
  23. copying lltm.egg-info/SOURCES.txt -> build/bdist.linux-x86_64/egg/EGG-INFO
  24. copying lltm.egg-info/dependency_links.txt -> build/bdist.linux-x86_64/egg/EGG-INFO
  25. copying lltm.egg-info/top_level.txt -> build/bdist.linux-x86_64/egg/EGG-INFO
  26. writing build/bdist.linux-x86_64/egg/EGG-INFO/native_libs.txt
  27. zip_safe flag not set; analyzing archive contents...
  28. __pycache__.lltm.cpython-36: module references __file__
  29. creating 'dist/lltm-0.0.0-py3.6-linux-x86_64.egg' and adding 'build/bdist.linux-x86_64/egg' to it
  30. removing 'build/bdist.linux-x86_64/egg' (and everything under it)
  31. Processing lltm-0.0.0-py3.6-linux-x86_64.egg
  32. removing '~/local/miniconda/lib/python3.6/site-packages/lltm-0.0.0-py3.6-linux-x86_64.egg' (and everything under it)
  33. creating ~/local/miniconda/lib/python3.6/site-packages/lltm-0.0.0-py3.6-linux-x86_64.egg
  34. Extracting lltm-0.0.0-py3.6-linux-x86_64.egg to ~/local/miniconda/lib/python3.6/site-packages
  35. lltm 0.0.0 is already the active version in easy-install.pth
  36. Installed ~/local/miniconda/lib/python3.6/site-packages/lltm-0.0.0-py3.6-linux-x86_64.egg
  37. Processing dependencies for lltm==0.0.0
  38. Finished processing dependencies for lltm==0.0.0

关于编译器的一个小注意事项:由于 ABI 版本问题,用于构建 C++ 扩展的编译器必须与 ABI 兼容,并且这里的编译器是必须是与构建 PyTorch 时采用的编译器一样的。实际上,这意味着你必须在 Linux 上使用 GCC 4.9 及更高版本。对于 Ubuntu 16.04 和其他更新的 Linux 发行版来说,这应该是默认的编译器。在MacOS上,你必须使用clang(没有任何与ABI版本相关的问题)。在最坏的情况下,你可以使用编译器从源代码构建 PyTorch,然后使用相同的编译器构建扩展。

构建扩展后,你只需使用在setup.py脚本中指定的名称在Python中导入它。请务必首先运行 import torch ,因为这将解析动态链接器必须看到的一些符号:

  1. In [1]: import torch
  2. In [2]: import lltm
  3. In [3]: lltm.forward
  4. Out[3]: <function lltm.PyCapsule.forward>

如果我们在函数或模块上调用help(),我们可以看到它的签名(signature)与我们的 C++ 代码匹配:

  1. In[4] help(lltm.forward)
  2. forward(...) method of builtins.PyCapsule instance
  3. forward(arg0: at::Tensor, arg1: at::Tensor, arg2: at::Tensor, arg3: at::Tensor, arg4: at::Tensor) -> List[at::Tensor]
  4. LLTM forward

既然我们现在能够从 Python 中调用我们的 C++ 函数,我们可以使用torch.autograd.Functiontorch.nn.Module来包装(warp)它们,使它们成为 PyTorch 中的一等公民(first class citizens,关键的一部分):

  1. import math
  2. import torch
  3. # Our module!
  4. import lltm
  5. class LLTMFunction(torch.autograd.Function):
  6. @staticmethod
  7. def forward(ctx, input, weights, bias, old_h, old_cell):
  8. outputs = lltm.forward(input, weights, bias, old_h, old_cell)
  9. new_h, new_cell = outputs[:2]
  10. variables = outputs[1:] + [weights]
  11. ctx.save_for_backward(*variables)
  12. return new_h, new_cell
  13. @staticmethod
  14. def backward(ctx, grad_h, grad_cell):
  15. outputs = lltm.backward(
  16. grad_h.contiguous(), grad_cell.contiguous(), *ctx.saved_variables)
  17. d_old_h, d_input, d_weights, d_bias, d_old_cell = outputs
  18. return d_input, d_weights, d_bias, d_old_h, d_old_cell
  19. class LLTM(torch.nn.Module):
  20. def __init__(self, input_features, state_size):
  21. super(LLTM, self).__init__()
  22. self.input_features = input_features
  23. self.state_size = state_size
  24. self.weights = torch.nn.Parameter(
  25. torch.empty(3 * state_size, input_features + state_size))
  26. self.bias = torch.nn.Parameter(torch.empty(3 * state_size))
  27. self.reset_parameters()
  28. def reset_parameters(self):
  29. stdv = 1.0 / math.sqrt(self.state_size)
  30. for weight in self.parameters():
  31. weight.data.uniform_(-stdv, +stdv)
  32. def forward(self, input, state):
  33. return LLTMFunction.apply(input, self.weights, self.bias, *state)

性能比较

现在我们可以使用并调用来自 PyTorch 的 C++ 代码,我们可以运行一个小的基准测试来看看我们在 C++ 中重写的操作的性能。我们将运行 LLTM 中的前向转播与反向传播几次并测量运行的时间:

  1. import torch
  2. batch_size = 16
  3. input_features = 32
  4. state_size = 128
  5. X = torch.randn(batch_size, input_features)
  6. h = torch.randn(batch_size, state_size)
  7. C = torch.randn(batch_size, state_size)
  8. rnn = LLTM(input_features, state_size)
  9. forward = 0
  10. backward = 0
  11. for _ in range(100000):
  12. start = time.time()
  13. new_h, new_C = rnn(X, (h, C))
  14. forward += time.time() - start
  15. start = time.time()
  16. (new_h.sum() + new_C.sum()).backward()
  17. backward += time.time() - start
  18. print('Forward: {:.3f} us | Backward {:.3f} us'.format(forward * 1e6/1e5, backward * 1e6/1e5))

如果运行我们在本文开头用纯 Python 编写原始 LLTM 的代码,我们将得到以下数字(在我的机器上):

  1. Forward: 506.480 us | Backward 444.694 us

然后是运行全新的 C++ 版本的代码:

  1. Forward: 349.335 us | Backward 443.523 us

我们已经可以看到前向传播函数的显着加速(超过30%)。对于反向传播函数而言,我们也是可以看到加速效果的,尽管加速的效果不是很明显。我在上面写的反向传播并没有经过特别优化,它绝对还可以进行改进。此外,PyTorch 的自动差分引擎可以自动并行化计算图,可以使用更高效的整体操作流,并且这也是用 C++ 实现,因此预计运行速度会很快。尽管如此,这是一个良好的开端。

在GPU设备上的性能

关于 PyTorch 的 ATen 后端的一个很好的事实是它抽象了你正在运行代码的计算设备。这意味着我们为CPU编写的代码也可以在GPU上运行,并且各个操作将相应地分派到以 GPU 优化过后的实现中去。对于某些操作,如矩阵乘法(如mmadmm),这是一个很大的胜利。让我们看一下使用 CUDA 张量运行 C++ 代码可以获得多少的性能提升。我们不需要对代码作出任何改变,我们只需要将我们的张量放在 Python 中的 GPU 内存中,在创建时添加device = cuda_device参数或在创建后使用.to(cuda_device)即可:

  1. import torch
  2. assert torch.cuda.is_available()
  3. cuda_device = torch.device("cuda") # device object representing GPU
  4. batch_size = 16
  5. input_features = 32
  6. state_size = 128
  7. # Note the device=cuda_device arguments here
  8. X = torch.randn(batch_size, input_features, device=cuda_device)
  9. h = torch.randn(batch_size, state_size, device=cuda_device)
  10. C = torch.randn(batch_size, state_size, device=cuda_device)
  11. rnn = LLTM(input_features, state_size).to(cuda_device)
  12. forward = 0
  13. backward = 0
  14. for _ in range(100000):
  15. start = time.time()
  16. new_h, new_C = rnn(X, (h, C))
  17. torch.cuda.synchronize()
  18. forward += time.time() - start
  19. start = time.time()
  20. (new_h.sum() + new_C.sum()).backward()
  21. torch.cuda.synchronize()
  22. backward += time.time() - start
  23. print('Forward: {:.3f} us | Backward {:.3f} us'.format(forward * 1e6/1e5, backward * 1e6/1e5))

再一次将我们的普通 PyTorch 代码与我们的 C++ 版本进行比较,现在两者都运行在 CUDA 设备上,我们科技再次看到性能得到了提升。 对于 Python/PyTorch 来说:

  1. Forward: 187.719 us | Backward 410.815 us

然后是 C++ / ATen:

  1. 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,这看起来就像下面这样简单:

  1. from torch.utils.cpp_extension import load
  2. lltm = load(name="lltm", sources=["lltm.cpp"])

在这里,我们为函数提供与setuptools相同的信息。 在后台,将执行以下操作:

  1. 创建临时目录 /tmp/torch_extensions/lltm
  2. 将一个 Ninja 构建文件发送到该临时目录,
  3. 将源文件编译为共享库
  4. 将此共享库导入为 Python 模块

实际上,如果你将verbose = True参数传递给cpp_extension.load(),该过程在进行的过程中将会告知你:

  1. Using /tmp/torch_extensions as PyTorch extensions root...
  2. Creating extension directory /tmp/torch_extensions/lltm...
  3. Emitting ninja build file /tmp/torch_extensions/lltm/build.ninja...
  4. Building extension module lltm...
  5. Loading extension module lltm...

生成的 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)和使用 NVIDIA 的nvcc编译器的CUDA源编译 C++ 源代码。以此来确保每个编译器处理它最好编译的文件。最终,它们将链接到一个可从 Python 代码中进行访问的共享库。

我们将从 C++ 文件开始,我们将其称为lltm_cuda.cpp,例如:

  1. #include <torch/torch.h>
  2. #include <vector>
  3. // CUDA forward declarations
  4. std::vector<at::Tensor> lltm_cuda_forward(
  5. at::Tensor input,
  6. at::Tensor weights,
  7. at::Tensor bias,
  8. at::Tensor old_h,
  9. at::Tensor old_cell);
  10. std::vector<at::Tensor> lltm_cuda_backward(
  11. at::Tensor grad_h,
  12. at::Tensor grad_cell,
  13. at::Tensor new_cell,
  14. at::Tensor input_gate,
  15. at::Tensor output_gate,
  16. at::Tensor candidate_cell,
  17. at::Tensor X,
  18. at::Tensor gate_weights,
  19. at::Tensor weights);
  20. // C++ interface
  21. #define CHECK_CUDA(x) AT_ASSERTM(x.type().is_cuda(), #x " must be a CUDA tensor")
  22. #define CHECK_CONTIGUOUS(x) AT_ASSERTM(x.is_contiguous(), #x " must be contiguous")
  23. #define CHECK_INPUT(x) CHECK_CUDA(x); CHECK_CONTIGUOUS(x)
  24. std::vector<at::Tensor> lltm_forward(
  25. at::Tensor input,
  26. at::Tensor weights,
  27. at::Tensor bias,
  28. at::Tensor old_h,
  29. at::Tensor old_cell) {
  30. CHECK_INPUT(input);
  31. CHECK_INPUT(weights);
  32. CHECK_INPUT(bias);
  33. CHECK_INPUT(old_h);
  34. CHECK_INPUT(old_cell);
  35. return lltm_cuda_forward(input, weights, bias, old_h, old_cell);
  36. }
  37. std::vector<at::Tensor> lltm_backward(
  38. at::Tensor grad_h,
  39. at::Tensor grad_cell,
  40. at::Tensor new_cell,
  41. at::Tensor input_gate,
  42. at::Tensor output_gate,
  43. at::Tensor candidate_cell,
  44. at::Tensor X,
  45. at::Tensor gate_weights,
  46. at::Tensor weights) {
  47. CHECK_INPUT(grad_h);
  48. CHECK_INPUT(grad_cell);
  49. CHECK_INPUT(input_gate);
  50. CHECK_INPUT(output_gate);
  51. CHECK_INPUT(candidate_cell);
  52. CHECK_INPUT(X);
  53. CHECK_INPUT(gate_weights);
  54. CHECK_INPUT(weights);
  55. return lltm_cuda_backward(
  56. grad_h,
  57. grad_cell,
  58. new_cell,
  59. input_gate,
  60. output_gate,
  61. candidate_cell,
  62. X,
  63. gate_weights,
  64. weights);
  65. }
  66. PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
  67. m.def("forward", &lltm_forward, "LLTM forward (CUDA)");
  68. m.def("backward", &lltm_backward, "LLTM backward (CUDA)");
  69. }

正如你所看到的,它主要是一个样板(boilerplate),检查和转发到我们将在 CUDA 文件中定义的函数。我们将这个文件命名为lltm_cuda_kernel.cu(注意.cu扩展名!)。NVCC 可以合理地编译 C++ 11,因此我们仍然可以使用 ATen 和 C++ 标准库(但torch.h不行)。 请注意,setuptools无法处理具有相同名称但扩展名不同的文件,因此如果使用setup.py方法而不是 JIT 方法,则必须为 CUDA 文件指定与 C++ 文件不同的名称(对于JIT) 方法,lltm.cpplltm.cu会正常工作)。 我们来看看这个文件的样子:

  1. #include <ATen/ATen.h>
  2. #include <cuda.h>
  3. #include <cuda_runtime.h>
  4. #include <vector>
  5. template <typename scalar_t>
  6. __device__ __forceinline__ scalar_t sigmoid(scalar_t z) {
  7. return 1.0 / (1.0 + exp(-z));
  8. }

在这里,我们可以看到我刚刚描述的头文件,以及我们使用 CUDA 特定的声明,如__device____forceinline__以及像exp这样的函数。让我们继续使用我们将需要用到的一些辅助函数:

  1. template <typename scalar_t>
  2. __device__ __forceinline__ scalar_t d_sigmoid(scalar_t z) {
  3. const auto s = sigmoid(z);
  4. return (1.0 - s) * s;
  5. }
  6. template <typename scalar_t>
  7. __device__ __forceinline__ scalar_t d_tanh(scalar_t z) {
  8. const auto t = tanh(z);
  9. return 1 - (t * t);
  10. }
  11. template <typename scalar_t>
  12. __device__ __forceinline__ scalar_t elu(scalar_t z, scalar_t alpha = 1.0) {
  13. return fmax(0.0, z) + fmin(0.0, alpha * (exp(z) - 1.0));
  14. }
  15. template <typename scalar_t>
  16. __device__ __forceinline__ scalar_t d_elu(scalar_t z, scalar_t alpha = 1.0) {
  17. const auto e = exp(z);
  18. const auto d_relu = z < 0.0 ? 0.0 : 1.0;
  19. return d_relu + (((alpha * (e - 1.0)) < 0.0) ? (alpha * e) : 0.0);
  20. }

现在实际上实现了一个函数,我们还需要两件事:一个函数执行我们不希望手动显式写入的操作并调用 CUDA 内核,然后实际的 CUDA 内核用于我们想要加速的部分。对于前向转播来说,第一个函数应如下所示:

  1. std::vector<at::Tensor> lltm_cuda_forward(
  2. at::Tensor input,
  3. at::Tensor weights,
  4. at::Tensor bias,
  5. at::Tensor old_h,
  6. at::Tensor old_cell) {
  7. auto X = at::cat({old_h, input}, /*dim=*/1);
  8. auto gates = at::addmm(bias, X, weights.transpose(0, 1));
  9. const auto batch_size = old_cell.size(0);
  10. const auto state_size = old_cell.size(1);
  11. auto new_h = at::zeros_like(old_cell);
  12. auto new_cell = at::zeros_like(old_cell);
  13. auto input_gate = at::zeros_like(old_cell);
  14. auto output_gate = at::zeros_like(old_cell);
  15. auto candidate_cell = at::zeros_like(old_cell);
  16. const int threads = 1024;
  17. const dim3 blocks((state_size + threads - 1) / threads, batch_size);
  18. AT_DISPATCH_FLOATING_TYPES(gates.type(), "lltm_forward_cuda", ([&] {
  19. lltm_cuda_forward_kernel<scalar_t><<<blocks, threads>>>(
  20. gates.data<scalar_t>(),
  21. old_cell.data<scalar_t>(),
  22. new_h.data<scalar_t>(),
  23. new_cell.data<scalar_t>(),
  24. input_gate.data<scalar_t>(),
  25. output_gate.data<scalar_t>(),
  26. candidate_cell.data<scalar_t>(),
  27. state_size);
  28. }));
  29. return {new_h, new_cell, input_gate, output_gate, candidate_cell, X, gates};
  30. }

这里主要关注的是AT_DISPATCH_FLOATING_TYPES宏和内核启动(由<<<...>>>进行表示)。虽然 ATen 会对我们所处理的张量的设备和数据类型进行抽象化,但是在运行时,张量仍将由具体设备上的具体类型的内存支持。因此,我们需要一种在运行时确定张量是什么类型的方法,然后选择性地调用相应的具有正确类型签名(signature)函数。手动完成这些部分,这将(概念上)看起来像这样:

  1. switch (tensor.type().scalarType()) {
  2. case at::ScalarType::Double:
  3. return function<double>(tensor.data<double>());
  4. case at::ScalarType::Float:
  5. return function<float>(tensor.data<float>());
  6. ...
  7. }

AT_DISPATCH_FLOATING_TYPES 的目的是为我们处理此次调度。它需要一个类型(在我们的例子中是gates.type()),一个名称(用于错误消息)和一个 lambda 函数。在这个 lambda 函数中,类型别名scalar_t是可用的,并且被定义为张量在该上下文中实际处于运行时的类型。因此,如果我们有一个模板函数(我们的 CUDA 内核将作为模板函数),我们可以用这个scalar_t别名实例化它,然后正确的函数就会被调用。 在这种情况下,我们还希望检索张量的数据指针作为scalar_t类型的指针。 如果你想调度所有类型而不仅仅是浮点类型(FloatDouble),你可以使用AT_DISPATCH_ALL_TYPES

请注意,我们使用普通 ATen 执行的一些操作。这些操作仍将在 GPU 上运行,但使用的是 ATen 的默认实现。这是有道理的,因为 ATen 将使用高度优化的例程来处理诸如矩阵乘法(例如addmm)或者是一些我们自己十分难以实现以及完成性能提升的操作,如卷积操作。

至于内核启动本身,我们在这里指定每个 CUDA 块将具有1024个线程,并且整个 GPU 网格被分成尽可能多的 1 x 1024 线程块,并以一组一个线程的方式填充我们的矩阵。例如,如果我们的状态(state)大小为2048且批量大小为4,那么我们将以每个块1024个线程完成启动,总共 4 x 2 = 8 个块。如果你之前从未听说过 CUDA “块”或“网格”,那么关于 CUDA 的介绍性阅读可能会有所帮助。

实际的 CUDA 内核非常简单(如果你以前进行过 GPU 编程):

  1. template <typename scalar_t>
  2. __global__ void lltm_cuda_forward_kernel(
  3. const scalar_t* __restrict__ gates,
  4. const scalar_t* __restrict__ old_cell,
  5. scalar_t* __restrict__ new_h,
  6. scalar_t* __restrict__ new_cell,
  7. scalar_t* __restrict__ input_gate,
  8. scalar_t* __restrict__ output_gate,
  9. scalar_t* __restrict__ candidate_cell,
  10. size_t state_size) {
  11. const int column = blockIdx.x * blockDim.x + threadIdx.x;
  12. const int index = blockIdx.y * state_size + column;
  13. const int gates_row = blockIdx.y * (state_size * 3);
  14. if (column < state_size) {
  15. input_gate[index] = sigmoid(gates[gates_row + column]);
  16. output_gate[index] = sigmoid(gates[gates_row + state_size + column]);
  17. candidate_cell[index] = elu(gates[gates_row + 2 * state_size + column]);
  18. new_cell[index] =
  19. old_cell[index] + candidate_cell[index] * input_gate[index];
  20. new_h[index] = tanh(new_cell[index]) * output_gate[index];
  21. }
  22. }

这里最感兴趣的是,我们能够完全并行地为门矩阵中的每个单独组件计算所有的这些逐点运算。如果你能想象必须用一个巨大的for循环来连续超过一百万个元素的情况,你也可以理解为什么改进之后速度会更快了。

反向传播遵循相同的模式,在这里将不再详细说明:

  1. template <typename scalar_t>
  2. __global__ void lltm_cuda_backward_kernel(
  3. scalar_t* __restrict__ d_old_cell,
  4. scalar_t* __restrict__ d_gates,
  5. const scalar_t* __restrict__ grad_h,
  6. const scalar_t* __restrict__ grad_cell,
  7. const scalar_t* __restrict__ new_cell,
  8. const scalar_t* __restrict__ input_gate,
  9. const scalar_t* __restrict__ output_gate,
  10. const scalar_t* __restrict__ candidate_cell,
  11. const scalar_t* __restrict__ gate_weights,
  12. size_t state_size) {
  13. const int column = blockIdx.x * blockDim.x + threadIdx.x;
  14. const int index = blockIdx.y * state_size + column;
  15. const int gates_row = blockIdx.y * (state_size * 3);
  16. if (column < state_size) {
  17. const auto d_output_gate = tanh(new_cell[index]) * grad_h[index];
  18. const auto d_tanh_new_cell = output_gate[index] * grad_h[index];
  19. const auto d_new_cell =
  20. d_tanh(new_cell[index]) * d_tanh_new_cell + grad_cell[index];
  21. d_old_cell[index] = d_new_cell;
  22. const auto d_candidate_cell = input_gate[index] * d_new_cell;
  23. const auto d_input_gate = candidate_cell[index] * d_new_cell;
  24. const auto input_gate_index = gates_row + column;
  25. const auto output_gate_index = gates_row + state_size + column;
  26. const auto candidate_cell_index = gates_row + 2 * state_size + column;
  27. d_gates[input_gate_index] =
  28. d_input_gate * d_sigmoid(gate_weights[input_gate_index]);
  29. d_gates[output_gate_index] =
  30. d_output_gate * d_sigmoid(gate_weights[output_gate_index]);
  31. d_gates[candidate_cell_index] =
  32. d_candidate_cell * d_elu(gate_weights[candidate_cell_index]);
  33. }
  34. }
  35. std::vector<at::Tensor> lltm_cuda_backward(
  36. at::Tensor grad_h,
  37. at::Tensor grad_cell,
  38. at::Tensor new_cell,
  39. at::Tensor input_gate,
  40. at::Tensor output_gate,
  41. at::Tensor candidate_cell,
  42. at::Tensor X,
  43. at::Tensor gate_weights,
  44. at::Tensor weights) {
  45. auto d_old_cell = at::zeros_like(new_cell);
  46. auto d_gates = at::zeros_like(gate_weights);
  47. const auto batch_size = new_cell.size(0);
  48. const auto state_size = new_cell.size(1);
  49. const int threads = 1024;
  50. const dim3 blocks((state_size + threads - 1) / threads, batch_size);
  51. AT_DISPATCH_FLOATING_TYPES(X.type(), "lltm_backward_cuda", ([&] {
  52. lltm_cuda_backward_kernel<scalar_t><<<blocks, threads>>>(
  53. d_old_cell.data<scalar_t>(),
  54. d_gates.data<scalar_t>(),
  55. grad_h.contiguous().data<scalar_t>(),
  56. grad_cell.contiguous().data<scalar_t>(),
  57. new_cell.contiguous().data<scalar_t>(),
  58. input_gate.contiguous().data<scalar_t>(),
  59. output_gate.contiguous().data<scalar_t>(),
  60. candidate_cell.contiguous().data<scalar_t>(),
  61. gate_weights.contiguous().data<scalar_t>(),
  62. state_size);
  63. }));
  64. auto d_weights = d_gates.t().mm(X);
  65. auto d_bias = d_gates.sum(/*dim=*/0, /*keepdim=*/true);
  66. auto d_X = d_gates.mm(weights);
  67. auto d_old_h = d_X.slice(/*dim=*/1, 0, state_size);
  68. auto d_input = d_X.slice(/*dim=*/1, state_size);
  69. return {d_old_h, d_input, d_weights, d_bias, d_old_cell, d_gates};
  70. }

将 C++/CUDA 操作与 PyTorch 集成

我们支持 CUDA 的操作与 PyTorch 的集成同样十分简单。如果你想写一个setup.py脚本,它可能看起来像这样:

  1. from setuptools import setup
  2. from torch.utils.cpp_extension import BuildExtension, CUDAExtension
  3. setup(
  4. name='lltm',
  5. ext_modules=[
  6. CUDAExtension('lltm_cuda', [
  7. 'lltm_cuda.cpp',
  8. 'lltm_cuda_kernel.cu',
  9. ])
  10. ],
  11. cmdclass={
  12. 'build_ext': BuildExtension
  13. })

我们现在使用CUDAExtension()而不是CppExtension()。我们可以只指定.cu文件和.cpp文件——库可以解决所有麻烦。JIT 机制则更简单:

  1. from torch.utils.cpp_extension import load
  2. lltm = load(name='lltm', sources=['lltm_cuda.cpp', 'lltm_cuda_kernel.cu'])

性能比较

我们希望并行化与融合我们代码与 CUDA 的逐点操作将改善我们的 LLTM 的性能。让我们看看是否成立。我们可以运行在前面列出的代码来进行基准测试。我们之前的最快版本是基于 CUDA 的 C++ 代码:

  1. Forward: 149.802 us | Backward 393.458 us

现在使用我们的自定义 CUDA 内核:

  1. Forward: 129.431 us | Backward 304.641 us

性能得到了更多的提升!

结论

你现在应该对 PyTorch 的 C++ 扩展机制以及使用它们的动机有一个很好的大致上的了解了。你可以在此处中找到本文中显示的代码示例。如果你有任何疑问,请使用论坛。如果你遇到任何问题,请务必查看我们的FAQ