在 C++ 中扩展调度器以支持新的后端¶
在本教程中,我们将逐步介绍扩展调度器以添加一个位于 pytorch/pytorch
存储库外部的新设备并维护它以与原生 PyTorch 设备保持同步所需的所有步骤。在此,我们假设您熟悉如何在 C++ 中注册已分派的运算符 以及如何编写 自定义自动微分函数。
注意
本教程涉及 PyTorch 内部许多正在积极改进的组件,如果您决定遵循本教程,请预期 API 会发生变化。我们将使本教程与最新的 API 保持同步。
什么是新的后端?¶
向 PyTorch 添加新的后端需要后端扩展者进行大量的开发和维护。在添加新的后端之前,让我们首先考虑一些常见的用例以及它们的推荐解决方案
如果您有针对现有 PyTorch 运算符的新算法,请向 PyTorch 发送 PR。
如果您想提出一个新的运算符,请向 PyTorch 发送功能请求/PR。
如果您想添加对新设备/硬件(如 Google TPU 和自定义芯片)的支持,这通常需要使用特定于硬件的 API 来编写内核,请遵循本教程并将树外后端添加到 PyTorch。
如果您想添加对现有运算符的支持,但使用不同的张量布局/表示形式(如稀疏和量化),这需要以一种在给定布局/表示形式限制的情况下更有效的方式编写内核,请遵循本教程并将树外后端添加到 PyTorch。
在本教程中,我们将主要关注添加新的树外设备。为不同的张量布局添加树外支持可能与设备共享许多共同步骤,但我们还没有看到此类集成的示例,因此可能需要 PyTorch 做额外的工作来支持它。
获取后端的分发键¶
PyTorch 运算符在 C++ 中实现,并通过 Python 绑定在 Python 前端提供。PyTorch 分发器将运算符的实现划分为多个内核,每个内核都与特定的分发键相关联。在 PyTorch 中支持新后端本质上意味着用 C++ 为每个 PyTorch 运算符编写内核,然后在分发器中将它们注册到表示您自定义后端的分发键。
分发键是您在分发器系统中的标识符。分发器查看输入张量上携带的分发键,并相应地调用正确的内核。PyTorch 提供了三个保留的分发键(及其对应的 Autograd 键)用于原型化树外后端扩展
PrivateUse1/AutogradPrivateUse1
PrivateUse2/AutogradPrivateUse2
PrivateUse3/AutogradPrivateUse3
您可以选择上述任何键来对您的自定义后端进行原型设计。要在 PrivateUse1
后端上创建张量,您需要在 TensorImpl
构造函数中设置分发键。
/* Example TensorImpl constructor */
TensorImpl(
Storage&& storage,
DispatchKeySet ks,
const caffe2::TypeMeta data_type);
// To create a TensorImpl on PrivateUse1 backend, pass in the following ks to TensorImpl creation.
DispatchKeySet ks = c10::DispatchKeySet{c10::DispatchKey::PrivateUse1, c10::DispatchKey::AutogradPrivateUse1};
请注意,上面的 TensorImpl
类假设您的张量由像 CPU/CUDA 这样的存储支持。我们还为没有存储的后端提供 OpaqueTensorImpl
。并且您可能需要调整/覆盖某些方法以适应您的自定义硬件。pytorch 存储库中的一个示例是 Vulkan TensorImpl。
注意
原型完成后,如果您计划对您的后端扩展进行定期发布,请随时向 pytorch/pytorch
提交 PR 以为您后端保留一个专用的分发键。
获取 PyTorch 运算符的完整列表¶
PyTorch 在生成的 build/aten/src/ATen/RegistrationDeclarations.h
文件中提供了可扩展 C++ 运算符的完整列表。此文件仅在从源代码构建 PyTorch 后可用。以下是该文件的一个片段
Tensor abs(const Tensor & self); // {"schema": "aten::abs(Tensor self) -> Tensor", "dispatch": "True", "default": "True"}
Tensor & abs_(Tensor & self); // {"schema": "aten::abs_(Tensor(a!) self) -> Tensor(a!)", "dispatch": "True", "default": "True"}
Tensor & abs_out(Tensor & out, const Tensor & self); // {"schema": "aten::abs.out(Tensor self, *, Tensor(a!) out) -> Tensor(a!)", "dispatch": "True", "default": "False"}
Tensor absolute(const Tensor & self); // {"schema": "aten::absolute(Tensor self) -> Tensor", "dispatch": "False", "default": "False"}
Tensor & absolute_(Tensor & self); // {"schema": "aten::absolute_(Tensor(a!) self) -> Tensor(a!)", "dispatch": "False", "default": "False"}
Tensor & absolute_out(Tensor & out, const Tensor & self); // {"schema": "aten::absolute.out(Tensor self, *, Tensor(a!) out) -> Tensor(a!)", "dispatch": "False", "default": "False"}
Tensor angle(const Tensor & self); // {"schema": "aten::angle(Tensor self) -> Tensor", "dispatch": "True", "default": "True"}
Tensor & angle_out(Tensor & out, const Tensor & self); // {"schema": "aten::angle.out(Tensor self, *, Tensor(a!) out) -> Tensor(a!)", "dispatch": "True", "default": "False"}
Tensor sgn(const Tensor & self); // {"schema": "aten::sgn(Tensor self) -> Tensor", "dispatch": "True", "default": "True"}
多个字段与单个运算符相关联。让我们以 abs_out
为例进行分解
Tensor & abs_out(Tensor & out, const Tensor & self);
是运算符的 C++ 签名,您的 C++ 内核应完全匹配此签名。aten::abs.out(Tensor self, *, Tensor(a!) out) -> Tensor(a!)
是表示运算符的唯一模式,与 C++ 签名相比,它还包含别名和变异注释。这是分发器用来查找运算符的唯一标识符。dispatch
和default
是布尔字段,提供有关原生 PyTorch 内核可以执行哪些操作的信息,从而暗示后端扩展程序是否需要实现内核。更多详细信息可以在 为新后端注册内核 中找到。
为新后端注册内核¶
要将您的内核注册到 PyTorch 分发器,您可以使用 在 C++ 中注册分发运算符 中描述的 TORCH_LIBRARY_IMPL
API。
TORCH_LIBRARY_IMPL(aten, PrivateUse1, m) {
m.impl(<schema_my_op1>, &my_op1);
m.impl(<schema_my_op2>, &my_op2);
m.impl(<schema_my_op2_backward>, &my_op2_backward);
}
现在让我们深入了解一下哪个运算符需要来自自定义后端的内核以及内核内部的内容。
PyTorch 目前有 1600 多个运算符,并且还在不断增加。后端扩展无法跟上这种速度是不现实的。即使对于 CPU 或 CUDA 等原生后端,为每个新操作编写专用内核也常常需要大量工作。
幸运的是,一些原生 PyTorch 内核的编写方式是将其分解为几个已知运算符的组合。换句话说,您只需要实现一组已知运算符(下面需要注册的操作)而不是所有 PyTorch 运算符。
PyTorch 运算符可以分为两类
需要注册的操作:这些操作的 PyTorch 原生实现是特定于后端的,因此需要为自定义后端提供内核。否则,在自定义后端上调用此类操作将导致错误。
在
RegistrationDeclarations.h
中,这些运算符的元数据中dispatch
设置为 True *并且*default
设置为 False。
注册是可选的:后端扩展程序可以跳过向这些操作注册而不会牺牲任何支持。但是,如果后端扩展程序想要覆盖 PyTorch 提供的默认内核,他们仍然可以将其自定义内核注册到其后端,并且分发器将仅对您的后端使用它。例如,PyTorch 的
max_pool2d
的当前实现将indices
作为前向输出的一部分返回,这会在 torch_xla 中产生开销,因此 torch_xla 为max_pool2d
注册了自己的内核。在
RegistrationDeclarations.h
中,这些运算符的元数据中dispatch
设置为 False *或*default
设置为 True。
新后端的 Autograd 支持¶
梯度公式大多是纯数学的,因此对于所有后端都是通用的。PyTorch 通常注册一个内核来为 Autograd 分发键设置别名,这意味着所有后端都可以使用它。
对于这些运算符,您不必担心它们的导数公式,您只需在 RegistrationDeclarations.h
中编写运算符的前向定义,PyTorch 就会自动为您处理反向。
Tensor my_op1(const Tensor& self, const Tensor& other) {
// call your backend-specific APIs to implement my_op so that
// it matches PyTorch's native behavior
}
TORCH_LIBRARY_IMPL(aten, PrivateUse1, m) {
m.impl(<schema_my_op1>, &my_op);
}
在某些情况下,PyTorch 反向内核实现也是特定于设备的,以便它们可以从每个后端中挤出最大性能。对于这些运算符,您会看到 RegistrationDeclarations.h
中显示 op_backward 作为*需要注册*。
Tensor my_op2_backward(const Tensor& self, const Tensor& other) {
// call your backend-specific APIs to implement my_op2_backward so that
// it matches PyTorch's native behavior
}
// Note backward kernel is still registered to PrivateUse1 instead of AutogradPrivateUse1.
// PyTorch will wrap your backward kernel with proper autograd setup and then link to it in
// my_op2's AutogradPrivateUse1 kernel.
TORCH_LIBRARY_IMPL(aten, PrivateUse1, m) {
m.impl(<schema_my_op2>, &my_op2);
m.impl(<schema_my_op2_backward>, &my_op2_backward);
}
在一些*罕见*的情况下,PyTorch 针对某些运算符的梯度公式可能存在不适用于所有后端的假设。在这些情况下,后端扩展程序可以选择通过将内核从 torch::autograd::Function 注册到相应的分发键(例如,如果您正在为后端使用 PrivateUse1,则为 AutogradPrivateUse1)来覆盖 PyTorch Autograd 层。
class MyAddFunction : public torch::autograd::Function<MyAddFunction> {
public:
static Tensor forward(AutogradContext *ctx, torch::Tensor self, torch::Tensor other) {
at::AutoNonVariableTypeMode g;
return myadd(self, other);
}
static tensor_list backward(AutogradContext *ctx, tensor_list grad_outputs) {
auto grad_output = grad_outputs[0];
return {grad_output, grad_output};
}
};
Tensor myadd_autograd(const Tensor& self, const Tensor& other) {
return MyAddFunction::apply(self, other)[0];
}
// Register the autograd kernel to AutogradPrivateUse1
TORCH_LIBRARY_IMPL(aten, AutogradPrivateUse1, m) {
m.impl(<myadd_schema>, &myadd_autograd);
}
// Register the inference kernel to PrivateUse1
TORCH_LIBRARY_IMPL(aten, PrivateUse1, m) {
m.impl(<myadd_schema>, &myadd);
}
使用此技巧,您可以完全控制后端中 my_add
运算符的训练和推理行为。以下是 pytorch/xla
存储库中的 一个示例。
构建扩展¶
树外后端通过向 PyTorch 添加 C++ 扩展来支持。一旦您的内核和注册准备就绪,您就可以通过编写一个使用 setuptools
编译 C++ 代码的 setup.py
脚本来构建 C++ 扩展。以下是来自 pytorch/xla 存储库 的简化示例
from setuptools import setup
from torch.utils.cpp_extension import BuildExtension, CppExtension
setup(
name='torch_xla',
ext_modules=[
CppExtension(
'_XLAC',
torch_xla_sources,
include_dirs=include_dirs,
extra_compile_args=extra_compile_args,
library_dirs=library_dirs,
extra_link_args=extra_link_args + \
[make_relative_rpath('torch_xla/lib')],
),
],
cmdclass={
'build_ext': Build, # Build is a derived class of BuildExtension
}
# more configs...
)
有关更多详细信息,请参阅 我们的 C++ 扩展教程。
自定义运算符支持¶
只要自定义运算符由现有 PyTorch 运算符(您的后端已支持)组成,您的新后端应该可以与 在 python 中扩展的自定义运算符 无缝协作,而无需编写任何新内核。
对于 在 C++ 中扩展的自定义运算符,它们通常带有 特定于后端的 C++ 内核实现,例如 torchvsion 中的 nms 内核 以及 自定义 Python API,例如 torch.ops.torchvision.nms。为了支持这些运算符,后端扩展程序需要为您的后端编写 C++ 内核,并将其正确注册到分发器中的相应命名空间,类似于支持 PyTorch 原生运算符。或者,您也可以在扩展中添加自定义 API,例如 torch_xla.core.functions.nms
,以满足这些临时请求。
JIT 支持¶
正如我们在 在 C++ 中注册分发运算符 中提到的,通过 m.impl() API 注册的内核支持以未装箱和已装箱的方式调用。换句话说,您的自定义后端也可以像 CPU 或 CUDA 等树内后端一样与我们的 JIT 追踪/脚本前端一起使用。您还可以为您的后端在 JIT 图上编写专门的优化过程。但我们这里不讨论它,因为我们还没有确定 JIT 中的集成点,因此当前的后端支持将暂时专注于急切前端。
针对原生 PyTorch 后端测试您的后端¶
PyTorch 允许使用其 通用设备类型测试框架 在多种设备类型上运行测试。您可以在 测试如何使用它 和 如何添加新的设备类型 中找到详细信息。添加后,使用通用设备类型测试框架的 PyTorch 测试也将使用您的设备类型运行。有关测试如何实例化的示例,请参阅 此 Wiki 页面。
使用您的设备类型运行 PyTorch 的现有测试套件对于确保正确性非常重要,但并非所有 PyTorch 功能都受每种设备类型的支持。通用设备类型测试框架允许进行相当大的自定义,以便设备类型可以选择运行哪些测试,它们支持哪些数据类型,甚至在比较张量以确保相等性时使用哪些精度。
一个使用通用设备类型测试框架且不随 PyTorch 一起提供的示例设备类型是 XLA。请参阅 其对通用设备类型测试框架的扩展,其中包含阻止列表测试、阻止列表数据类型和覆盖测试精度的示例。
通用设备类型测试框架正在积极开发中。要请求功能,请在 PyTorch 的 Github 上提交问题。
向后兼容性¶
目前 PyTorch 无法保证已注册运算符的向后兼容性。可以根据需要添加/修改/删除运算符及其模式。已注册的内核必须与 PyTorch 版本*完全*相同。如果 PyTorch 为运算符添加了更多参数(即使有默认值),则您的旧注册将无法工作,除非更新为匹配 PyTorch 的新签名。
因此,我们强烈建议仅在 PyTorch 主要版本发布时同步树外后端扩展,以最大程度地减少开发中断。PyTorch 采用季度发布节奏。后端扩展程序应加入 pytorch.slack.com 上的#announcement 频道,以获取有关发布的最新更新。
已知问题和附加说明¶
并非所有测试套件都已实现设备通用。可扩展的测试类可以通过在 PyTorch 代码库中搜索
instantiate_device_type_tests
来找到,例如TestTorchDeviceType, TestViewOps, TestTensorDeviceOps, TestTypePromotion
等。在 C++ 中没有扩展点用于在自定义后端上序列化 Python 张量对象。目前,您只能通过修改PyTorch 张量 __reduce_ex__ 方法 或在树外存储库中进行猴子补丁来扩展它。
如果您的后端不允许直接内存访问,则应格外注意支持视图操作,因为它们应该共享存储。视图张量的更改需要传播到其基础张量,反之亦然。
如果您的后端不适用于原生 PyTorch 优化器(例如,需要在反向传播中携带要更新的状态,如 torch-xla),则在 C++ 中没有优化器的扩展点。此类用例目前只能通过添加自定义 API 或在树外存储库中进行猴子补丁来实现。
未来工作¶
使 PyTorch 中的每个组件都能够无缝地扩展到树外后端需要对 PyTorch 内部进行大量更改。以下列出了一些我们正在积极努力改进未来体验的项目。
提高通用测试框架的测试覆盖率。
改进
Math
内核覆盖范围并进行更全面的测试,以确保Math
内核的行为与其他后端(如CPU/CUDA
)一致。重构
RegistrationDeclarations.h
以承载最少的信息,并尽可能地重用 PyTorch 的代码生成。支持后端回退内核,以自动将输入转换为 CPU 并将结果转换回自定义后端。即使您没有为每个运算符编写内核,这也能实现“完整”的运算符覆盖。
保持联系¶
如有任何问题或讨论,请使用PyTorch 开发者讨论区。如果您有任何功能请求或错误报告,请在 GitHub 上提交问题。
如果您有兴趣参与上述任何未来工作项目(例如,在 C++ 中为 PyTorch 运算符添加更多Math
内核),请通过 GitHub 或 Slack 与我们联系!