在 C++ 中注册分发算子#
创建日期: 2020年7月22日 | 最后更新: 2024年7月22日 | 最后验证: 2024年11月5日
警告
本教程自 PyTorch 2.4 起已被弃用。请参阅 PyTorch 自定义算子 以获取关于使用自定义算子扩展 PyTorch 的最新指南。
调度器(Dispatcher)是 PyTorch 的一个内部组件,负责在调用类似 torch::add 的函数时确定实际应运行的代码。这并非易事,因为 PyTorch 算子需要处理许多“层叠”在一起的交叉关注点。以下是它处理的部分内容示例:
根据输入张量的设备类型,在算子的 CPU 和 CUDA 实现之间切换。
根据是否需要自动求导(autograd),在算子的自动求导实现和后端实现之间切换。
在必要时应用自动混合精度(AMP)的自动转换(autocasting)。
当算子在
vmap调用下运行时,应用批处理规则。如果您正在追踪模型以进行导出,则追踪算子的执行过程。
如果在您的 自定义算子代码 中发现自己正在手动编写 if 语句来处理这些情况,调度器 API 可以帮助您组织代码。(相反,如果您的自定义算子非常简单且仅用于 CPU 推理,您可能不需要使用调度器,直接使用基础 API 即可。)
在本教程中,我们将介绍如何构建自定义算子注册以使用调度器来组织各种组件。我们假设您已经熟悉如何 注册算子 以及如何编写 自定义自动求导函数。
定义模式(Schema)和后端实现#
调度器的基本原则是,它将算子的实现拆分为多个内核(Kernel),每个内核实现特定 分发键(dispatch key)(如 CPU、CUDA)的功能。调度器在您调用算子时会确定优先级最高的分发键(通过查看张量参数以及某些线程局部状态来完成),并将控制权转移给该分发键对应的内核。最终效果是,当您调用算子时,我们首先执行自动求导内核,然后根据传入张量的设备类型重新分发(redispatch)到后端内核。
让我们看看实现这一过程涉及的各个部分。首先,我们必须定义相关算子的模式。与简单的 pybind11 风格的算子注册不同,此时我们实际上并不提供算子的实现;我们只是提供一个模式字符串,指定所有其他内核都将遵循的算子类型签名。
TORCH_LIBRARY(myops, m) {
m.def("myadd(Tensor self, Tensor other) -> Tensor");
}
接下来,我们需要提供该算子的一些实现。具体来说,这是一个非常简单的 CPU 加法实现:
Tensor myadd_cpu(const Tensor& self_, const Tensor& other_) {
TORCH_CHECK(self_.sizes() == other_.sizes());
TORCH_INTERNAL_ASSERT(self_.device().type() == DeviceType::CPU);
TORCH_INTERNAL_ASSERT(other_.device().type() == DeviceType::CPU);
Tensor self = self_.contiguous();
Tensor other = other_.contiguous();
Tensor result = torch::empty(self.sizes(), self.options());
const float* self_ptr = self.data_ptr<float>();
const float* other_ptr = other.data_ptr<float>();
float* result_ptr = result.data_ptr<float>();
for (int64_t i = 0; i < result.numel(); i++) {
result_ptr[i] = self_ptr[i] + other_ptr[i];
}
return result;
}
我们希望将此函数注册为 myops::myadd 的实现。然而,简单的注册方式(def("myadd", myadd_cpu))会将内核注册为在所有情况下运行,即使张量不是 CPU 张量!(在内部,我们将这些称为“全捕获”(catch-all)内核,因为它们捕获所有情况。)为了确保 myadd_cpu 仅针对 CPU 张量运行,我们可以使用 TORCH_LIBRARY_IMPL 宏:
TORCH_LIBRARY_IMPL(myops, CPU, m) {
m.impl("myadd", myadd_cpu);
}
TORCH_LIBRARY_IMPL 允许我们在特定的分发键(本例中为 CPU)上为算子注册实现。对 impl 的每次调用都将一个 CPU 内核与相应的算子(我们在 TORCH_LIBRARY 块中预定义的)关联起来。如果我们还有一个 CUDA 实现 myadd_cuda,我们可以在单独的 TORCH_LIBRARY_IMPL 块中注册它。
TORCH_LIBRARY_IMPL(myops, CUDA, m) {
m.impl("myadd", myadd_cuda);
}
这些注册可以分布在不同的文件甚至不同的库边界中;因此,例如,您可以将这两个 TORCH_LIBRARY_IMPL 块编译成单独的 myops_cpu 和 myops_cuda 动态库。总的来说,您的注册结构将如下所示:
一个单一的
TORCH_LIBRARY,在中心位置列出您命名空间中的每个自定义算子。每个分发键对应一个
TORCH_LIBRARY_IMPL,用于注册该键的实现(例如 CPU 或 CUDA)。如果愿意,您可以进一步将TORCH_LIBRARY_IMPL块细分为每个算子一个块。这在您每个算子实现都有单独文件,但又不想在头文件中暴露算子时非常方便;您可以直接将注册代码放在定义算子的 cpp 文件中。
注意
您知道吗?您还可以为 PyTorch 中现有的核心算子编写 TORCH_LIBRARY_IMPL 块。这就是 PyTorch 对 XLA 支持的实现方式:torch_xla 库包含一个 TORCH_LIBRARY_IMPL,为 XLA 分发键上的所有基本算子提供了实现。
对于不需要自动求导的算子#
注意:本节仅适用于 PyTorch >= 1.10 的版本。
在下一节中,我们将讨论如何为算子添加自动求导支持。但对于不需要自动求导支持的算子,应注册以下内核以提高可用性,并使您的算子表现得像 PyTorch 的内置算子一样。
TORCH_LIBRARY_IMPL(myops, Autograd, m) {
m.impl(op, autogradNotImplementedFallback());
}
上述代码行注册了一个 Autograd 内核,它在向前传播时附加一个伪 NotImplemented 节点(保留输入的 require_grad 属性)。在反向传播时,NotImplemented 节点会引发错误。这对于调试大型模型很有帮助,因为之前很难准确定位在前向传播过程中 requires_grad 属性是在哪里丢失的。
原地操作或视图操作#
为了确保正确性和最佳性能,如果您的算子原地修改输入或返回与其中一个输入别名的张量,应采取两个额外的步骤:
除了上述
Autograd内核外,还要注册一个ADInplaceOrView内核。该内核处理必要的簿记工作,以确保原地操作或视图操作的正确性。需要注意的是,此ADInplaceOrView内核应仅与autogradNotImplementedFallback一起使用。
TORCH_LIBRARY_IMPL(myops, Autograd, m) {
m.impl(op, autogradNotImplementedFallback());
}
TORCH_LIBRARY_IMPL(myops, ADInplaceOrView, m) {
m.impl(op, autogradNotImplementedInplaceOrViewFallback());
}
上面注册的
Autograd或ADInplaceOrView装箱(boxed)内核依赖于其逻辑中的算子模式信息。如果您的算子原地修改输入或返回与其中一个输入别名的张量,确保您的模式正确反映这一点非常重要。有关如何标注模式的更多信息,请参阅 此处。
添加自动求导支持#
至此,我们已经拥有了一个同时具备 CPU 和 CUDA 实现的算子。我们该如何为其添加自动求导支持呢?正如您可能猜到的,我们将注册一个自动求导内核(类似于 自定义自动求导函数 教程中所述的)!然而,有一个转折:与 CPU 和 CUDA 内核不同,自动求导内核需要 重新分发(redispatch):它需要调用回调度器以找到推理内核(例如 CPU 或 CUDA 实现)。
因此,在编写自动求导内核之前,让我们编写一个 分发函数(dispatching function),它调用调度器来为您的算子找到正确的内核。此函数构成了您算子的公共 C++ API——事实上,PyTorch C++ API 中的所有张量函数在底层都是以相同方式调用调度器的。分发函数如下所示:
Tensor myadd(const Tensor& self, const Tensor& other) {
static auto op = torch::Dispatcher::singleton()
.findSchemaOrThrow("myops::myadd", "")
.typed<decltype(myadd)>();
return op.call(self, other);
}
让我们分解一下:
在第一行中,我们从调度器中查找与我们将要分发的算子相对应的类型化算子句柄。
findSchemaOrThrow接受两个参数:算子的(带命名空间的)名称和算子的重载名称(通常只是空字符串)。typed将动态类型句柄转换为静态类型句柄(执行运行时测试以确保您提供了正确的 C++ 类型),以便我们可以对其进行正常的 C++ 调用。我们传递decltype(myadd),因为分发函数的类型与注册到调度器的底层内核的类型相同。为了性能,此计算在静态变量中完成,因此我们只需要执行一次(较慢的)查找。如果您输入了想要调用的算子名称拼写错误,此查找将在您第一次调用该函数时报错。
在第二行中,我们只需使用传入分发函数的所有参数
call算子句柄。这实际上将调用调度器,最终控制权将转移到适合此调用的任何内核。
有了分发函数,我们现在可以编写自动求导内核了:
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];
}
自动求导函数像往常一样使用 torch::autograd::Function 编写,只是我们不在 forward() 中直接编写实现,而是:
使用
at::AutoNonVariableTypeModeRAII 守卫关闭自动求导处理,然后调用分发函数
myadd以调用回调度器。
如果没有 (1),您的调用将陷入无限循环(并导致栈溢出),因为 myadd 会将您发送回此函数(因为优先级最高的分发键仍然是自动求导)。有了 (1),自动求导将从考虑的分发键集中排除,我们将转到下一个处理器,即 CPU 或 CUDA。
现在,我们可以像注册 CPU/CUDA 函数一样注册此函数:
TORCH_LIBRARY_IMPL(myops, Autograd, m) {
m.impl("myadd", myadd_autograd);
}
注意
在此示例中,我们将内核注册到 Autograd,这将其安装为所有后端的自动求导内核。您还可以通过使用相应的特定后端分发键(例如 AutogradCPU 或 AutogradCUDA)来注册针对特定后端的优化内核。要更详细地探索这些和其他分发键选项,请查看 torch/_python_dispatcher.py 中提供的 PythonDispatcher 工具。
超越自动求导#
从某种意义上说,调度器并没有做太多事情:它所做的只是实现了一个类似这样的简易 if 语句:
class MyAddFunction : ... {
public:
static Tensor forward(
AutogradContext *ctx, torch::Tensor self, torch::Tensor other) {
if (self.device().type() == DeviceType::CPU) {
return add_cpu(self, other);
} else if (self.device().type() == DeviceType::CUDA) {
return add_cuda(self, other);
} else {
TORCH_CHECK(0, "Unsupported device ", self.device().type());
}
}
...
}
那么为什么要使用调度器呢?有几个原因:
它是去中心化的。您可以组装算子的所有部分(CPU、CUDA、自动求导),而无需编写一个引用所有这些部分的中心化 if 语句。重要的是,第三方可以为其他方面注册额外的实现,而无需修改算子的原始定义。我们将在 为新后端扩展调度器 中详细讨论扩展调度器。
它支持比 CPU、CUDA 和自动求导更多的分发键。您可以在
c10/core/DispatchKey.h中查看 PyTorch 目前实现的分发键完整列表。这些分发键为算子实现了各种可选功能,如果您决定让您的自定义算子支持这些功能,只需为适当的键注册一个内核即可。调度器实现了对装箱回退函数(boxed fallback functions)的支持,这些函数可以实现一次并应用于系统中的所有算子。装箱回退可用于为分发键提供默认行为;如果您使用调度器实现算子,您也就选择了所有这些操作的回退。
以下是您可能需要为其定义算子的一些特定分发键:
自动转换(Autocast)#
Autocast 分发键实现了对 自动混合精度 (AMP) 的支持。自动转换包装内核通常会在运行算子之前将传入的 float16 或 float32 CUDA 张量转换为某种首选精度。例如,浮点 CUDA 张量上的矩阵乘法和卷积通常在 float16 下运行得更快且占用内存更少,而不会损害收敛性。自动转换包装器仅在 自动转换启用的上下文 中有效。
这是一个用于假设自定义矩阵乘法的自动转换包装器及其注册方式:
// Autocast-specific helper functions
#include <ATen/autocast_mode.h>
Tensor mymatmul_autocast(const Tensor& self, const Tensor& other) {
c10::impl::ExcludeDispatchKeyGuard no_autocast(c10::DispatchKey::Autocast);
return mymatmul(at::autocast::cached_cast(at::kHalf, self),
at::autocast::cached_cast(at::kHalf, other));
}
TORCH_LIBRARY_IMPL(myops, Autocast, m) {
m.impl("mymatmul", mymatmul_autocast);
}
cached_cast(kHalf, tensor) 如果 tensor 是 CUDA 且为 float32,则将 tensor 转换为 float16,否则保持 tensor 不变(参见原生自动转换算子的 资格政策)。这确保了如果网络在 float16 和 float32 CUDA 张量的任意混合上调用 mymatmul,mymatmul 将以 float16 运行。同时,对具有非 CUDA、整数类型或 float64 输入的 mymatmul 的调用不受影响。建议使用 cached_cast 来遵循您自己的自动转换包装器中的原生资格政策,但这不是强制性的。例如,如果您想强制所有输入类型都以 float16 执行,您可以使用 return mymatmul(self.half(), other.half()); 而不是使用 cached_cast。
请注意,与我们的自动求导内核一样,我们在重新分发之前从分发中排除了 Autocast 键。
默认情况下,如果没有提供自动转换包装器,我们会直接回退到常规算子实现(不发生自动转换)。(在此示例中我们没有使用 myadd,因为逐点加法不需要自动转换,应该直接回退。)
什么时候应该注册自动转换包装器?遗憾的是,对于算子的首选精度没有硬性规定。您可以通过查看 转换列表 来了解某些原生算子的首选精度。一般指南如下:
执行归约的算子可能应该以
float32执行,底层执行卷积或 GEMM 的任何算子都可能应该以
float16执行,以及具有多个浮点张量输入的其他算子应将它们标准化为通用精度(除非实现支持不同精度的输入)。
如果您的自定义算子属于第三类,promote_type 模板有助于找出输入张量中存在的最宽浮点类型,这是执行类型的最安全选择:
#include <ATen/autocast_mode.h>
Tensor my_multiple_input_op_autocast(const Tensor& t0, const Tensor& t1) {
c10::impl::ExcludeDispatchKeyGuard no_autocast(c10::DispatchKey::Autocast);
// The required at::kHalf argument is an optimistic initial guess.
auto exec_type = at::autocast::promote_type(at::kHalf, t0, t1);
return my_multiple_input_op(at::autocast::cached_cast(exec_type, t0),
at::autocast::cached_cast(exec_type, t1));
}
如果您的自定义算子是 支持自动求导的,您只需为注册了自动求导包装器的名称编写并注册一个同名的自动转换包装器即可。例如,如果您想为自动求导部分中显示的 myadd 函数编写自动转换包装器,您只需要:
Tensor myadd_autocast(const Tensor& self, const Tensor& other) {
c10::impl::ExcludeDispatchKeyGuard no_autocast(c10::DispatchKey::Autocast);
return myadd(at::autocast::cached_cast(<desired dtype>, self),
at::autocast::cached_cast(<desired dtype>, other));
}
TORCH_LIBRARY_IMPL(myops, Autocast, m) {
m.impl("myadd", myadd_autocast);
}
不需要单独的体操动作来使反向传播方法兼容自动转换。但是,在自定义自动求导函数中定义的反向传播方法将以自动转换设置为前向传播方法设置的相同数据类型运行,因此您应该为您的前向和反向传播方法选择一个合适的 <desired dtype>。
批处理(Batched)#
批处理张量允许您以“每个示例”的方式编写代码,然后在 vmap 调用下自动进行批处理。编写批处理规则的 API 目前正在开发中,但一旦稳定,您可以通过在 Batched 分发键处注册内核来为您的算子添加对 vmap 的支持。
追踪器(Tracer)#
Tracer 分发键实现了对在运行 torch.jit.trace 时将算子调用记录到追踪中的支持。我们打算提供一个装箱回退来实现任意算子的追踪,请参阅 问题 #41478 以跟踪进度。