在 C++ 中注册一个分派操作符
本教程自 PyTorch 2.4 起已弃用。请参阅 PyTorch 自定义运算符 获取有关使用自定义运算符扩展 PyTorch 的最新指南。
调度器是 PyTorch 的一个内部组件,负责在您调用诸如 torch::add
这样的函数时,确定实际应该执行哪些代码。这可能并不简单,因为 PyTorch 操作需要处理许多相互“分层”的横切关注点。以下是一些它需要处理的事项示例:
-
根据输入张量的设备,在操作符的 CPU 和 CUDA 实现之间切换。
-
根据是否需要自动梯度处理,在操作符的自动梯度和后端实现之间切换。
-
在需要时应用自动混合精度的自动类型转换。
-
当操作符在
vmap
调用下运行时,应用批处理规则。 -
如果要导出模型,则跟踪操作的执行。
如果在您的自定义算子代码中,您发现自己手动编写 if 语句来处理这些情况,调度器 API 可以帮助您组织代码。(相反,如果您的自定义算子非常简单且仅用于 CPU 推理,您可能不需要使用调度器,只需使用基础 API 即可。)
在本教程中,我们将描述如何构建自定义算子注册,以便使用调度器来组织各个组件。我们假设您已经熟悉如何注册一个算子以及如何编写自定义 autograd 函数。
定义模式和后端实现
调度器背后的基本原理是将操作符的实现划分为多个内核,每个内核负责实现特定调度键的功能,例如 CPU、CUDA。调度器会在调用操作符时确定最高优先级的调度键(这是通过查看张量参数以及一些线程局部状态来完成的),然后将控制权转移到该调度键对应的内核。最终的效果是,当您调用一个操作符时,我们会先执行 Autograd 内核,然后根据传入张量的设备类型重新分派到后端内核。
让我们来看一下实现这一目标的各个部分。首先,我们必须为所讨论的操作符定义模式。与简单的 pybind11 风格操作符注册不同,我们实际上并不在这一步骤提供操作符的实现;我们只是提供一个模式字符串,指定操作符的类型签名,所有其他内核都将遵循该签名:
TORCH_LIBRARY(myops,m){
m.def("myadd(Tensor self, Tensor other) -> Tensor");
}
接下来,我们需要实际提供这个操作符的一些实现。为了具体化,这里是一个非常简单的 CPU 上的加法实现:
Tensormyadd_cpu(constTensor&self_,constTensor&other_){
TORCH_CHECK(self_.sizes()==other_.sizes());
TORCH_INTERNAL_ASSERT(self_.device().type()==DeviceType::CPU);
TORCH_INTERNAL_ASSERT(other_.device().type()==DeviceType::CPU);
Tensorself=self_.contiguous();
Tensorother=other_.contiguous();
Tensorresult=torch::empty(self.sizes(),self.options());
constfloat*self_ptr=self.data_ptr<float>();
constfloat*other_ptr=other.data_ptr<float>();
float*result_ptr=result.data_ptr<float>();
for(int64_ti=0;i<result.numel();i++){
result_ptr[i]=self_ptr[i]+other_ptr[i];
}
returnresult;
}
我们希望将此函数注册为 myops::myadd
的实现。然而,简单的注册方式(def("myadd", myadd_cpu)
)会将此内核注册为在所有情况下运行,即使张量不是 CPU 张量!(在内部,我们称这些为“捕获所有”内核,因为它们会捕获所有情况。)为了确保 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
,用于在集中位置列出您命名空间中的所有自定义操作符。 -
每个调度键(如 CPU 或 CUDA)对应一个
TORCH_LIBRARY_IMPL
,用于为该键注册实现。如果您愿意,可以进一步将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
盒式内核在其逻辑中依赖于操作符的模式信息。如果您的操作符对输入进行原地修改或返回一个与某个输入共享内存的张量,请确保您的模式正确反映了这一点。有关如何注释模式的更多信息,请参见此处。
添加自动求导支持
此时,我们已经有了一个包含 CPU 和 CUDA 实现的算子。如何为其添加自动求导支持呢?正如您可能猜到的,我们将注册一个自动求核(类似于自定义自动求导函数教程中描述的内容)!不过,这里有一个区别:与 CPU 和 CUDA 核不同,自动求核需要重新分发:它需要回调到分发器以获取推理核,例如 CPU 或 CUDA 实现。
因此,在编写自动求核之前,我们先编写一个分发函数,该函数调用分发器以找到适合您算子的核。这个函数构成了您算子的公共 C++ API——事实上,PyTorch C++ API 中的所有张量函数在底层都以相同的方式调用分发器。以下是分发函数的样子:
Tensormyadd(constTensor&self,constTensor&other){
staticautoop=torch::Dispatcher::singleton()
.findSchemaOrThrow("myops::myadd","")
.typed<decltype(myadd)>();
returnop.call(self,other);
}
让我们来详细分解一下:
-
在第一行中,我们从调度器中查找一个类型化的操作符句柄,该句柄对应我们将要调度到的操作符。
findSchemaOrThrow
接受两个参数:操作符的(命名空间限定的)名称和操作符的重载名称(通常为空字符串)。typed
将动态类型的句柄转换为静态类型的句柄(并进行运行时测试以确保您提供了正确的 C++ 类型),以便我们可以对其进行正常的 C++ 调用。我们传递decltype(myadd)
,因为调度函数的类型与注册到调度器的底层内核的类型相同。出于性能考虑,此计算在静态变量中完成,因此我们只需执行一次(较慢的)查找。如果您拼错了要调用的操作符名称,则在首次调用此函数时,此查找将出错。
-
在第二行中,我们简单地使用传递给调度函数的所有参数
call
操作符句柄。这将实际调用调度器,最终控制权将转移到适合此调用的内核。
有了 dispatch 函数,我们现在可以编写自动求导内核了:
classMyAddFunction:publictorch::autograd::Function<MyAddFunction>{
public:
staticTensorforward(
AutogradContext*ctx,torch::Tensorself,torch::Tensorother){
at::AutoNonVariableTypeModeg;
returnmyadd(self,other);
}
statictensor_listbackward(AutogradContext*ctx,tensor_listgrad_outputs){
autograd_output=grad_outputs[0];
return{grad_output,grad_output};
}
};
Tensormyadd_autograd(constTensor&self,constTensor&other){
returnMyAddFunction::apply(self,other)[0];
}
自动求导函数的编写与普通的 torch::autograd::Function
类似,区别在于我们不会直接在 forward()
中编写实现,而是:
-
使用
at::AutoNonVariableTypeMode
RAII 防护关闭自动求导处理,然后 -
调用分发函数
myadd
以回退到分发器。
如果没有(1),您的调用将会无限循环(并导致栈溢出),因为 myadd
会将您送回此函数(因为最高优先级的调度键仍然是 autograd)。有了(1),autograd 将从考虑的调度键集合中排除,我们将转到下一个处理程序,即 CPU 或 CUDA。
我们现在可以像注册 CPU/CUDA 函数一样注册此函数:
TORCH_LIBRARY_IMPL(myops,Autograd,m){
m.impl("myadd",myadd_autograd);
}
在这个示例中,我们将内核注册到
Autograd
,这会将其安装为所有后端的自动求导内核。您还可以通过使用相应的后端特定的调度键来为特定后端注册优化的内核,例如AutogradCPU
或AutogradCUDA
。要更详细地了解这些及其他调度键选项,请查看 torch/_python_dispatcher.py 中提供的PythonDispatcher
工具。
超越自动求导
从某种意义上说,调度器并没有做太多事情:它所做的只是实现了一个增强版的 if 语句,类似于这样:
classMyAddFunction:...{
public:
staticTensorforward(
AutogradContext*ctx,torch::Tensorself,torch::Tensorother){
if(self.device().type()==DeviceType::CPU){
returnadd_cpu(self,other);
}elseif(self.device().type()==DeviceType::CUDA){
returnadd_cuda(self,other);
}else{
TORCH_CHECK(0,"Unsupported device ",self.device().type());
}
}
...
}
那么为什么要使用 dispatcher 呢?有以下几点原因:
-
它是去中心化的。您可以在不编写一个集中式的、引用所有部分(CPU、CUDA、Autograd)的 if 语句的情况下,组装操作符的所有部分。重要的是,第三方可以为其他方面注册额外的实现,而无需修改操作符的原始定义。我们将在为新后端扩展调度器中进一步讨论如何扩展调度器。
-
它支持比 CPU、CUDA 和 Autograd 更多的调度键。您可以在
c10/core/DispatchKey.h
中查看当前在 PyTorch 中实现的所有调度键的完整列表。这些调度键为操作符实现了各种可选功能,如果您决定让自定义操作符支持这些功能,只需为适当的键注册一个内核即可。 -
调度器实现了对盒式回退函数的支持,这些函数可以一次性实现并应用于系统中的所有操作符。盒式回退可用于为调度键提供默认行为;如果您使用调度器来实现操作符,您也将自动获得所有这些操作的回退功能。
以下是一些您可能需要为其定义操作符的特殊分发键。
Autocast
Autocast 调度键实现了对自动混合精度(AMP)的支持。Autocast 包装器内核通常在运行操作之前将传入的 float16
或 float32
CUDA 张量转换为某种首选精度。例如,浮点 CUDA 张量上的矩阵乘法和卷积通常在 float16
下运行得更快且使用更少的内存,而不会影响收敛性。Autocast 包装器仅在启用 Autocast 的上下文中生效。
这是一个假设的自定义矩阵乘法(matmul)的自动类型转换包装器及其注册代码:
// Autocast-specific helper functions
#include<ATen/autocast_mode.h>
Tensormymatmul_autocast(constTensor&self,constTensor&other){
c10::impl::ExcludeDispatchKeyGuardno_autocast(c10::DispatchKey::Autocast);
returnmymatmul(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
类型,则将其转换为 float16
,否则保持 tensor
不变(参见原生自动转换操作的资格策略)。这确保了当网络在任何混合 float16
和 float32
的 CUDA 张量上调用 mymatmul
时,mymatmul
会在 float16
下运行。同时,使用非 CUDA、整数类型或 float64
输入调用 mymatmul
不会受到影响。建议在您自己的自动转换封装器中使用 cached_cast
来遵循原生资格策略,但这不是强制要求。例如,如果您想强制所有输入类型都以 float16
执行,可以使用 return mymatmul(self.half(), other.half());
而不是使用 cached_cast
。
请注意,与我们的 autograd 内核类似,我们在重新分发之前将 Autocast
键从分发中排除。
默认情况下,如果没有提供 autocast 包装器,我们直接回退到常规的操作符实现(不发生自动类型转换)。(我们没有在这个示例中使用 myadd
,因为逐点加法不需要自动类型转换,应该直接回退。)
何时应该注册 autocast 包装器?遗憾的是,并没有明确的操作符首选精度的规则。您可以通过查看 cast lists 来了解一些原生操作符的首选精度。一般建议如下:
-
执行归约操作的运算可能应该在
float32
下执行, -
任何在底层进行卷积或矩阵乘法运算的操作可能应该在
float16
下执行, -
其他具有多个浮点张量输入的运算应将它们标准化为统一的精度(除非实现支持不同精度的输入)。
如果您的自定义操作属于第三类,promote_type
模板可以帮助确定输入张量中最宽的浮点类型,这是执行类型的最安全选择:
#include<ATen/autocast_mode.h>
Tensormy_multiple_input_op_autocast(constTensor&t0,constTensor&t1){
c10::impl::ExcludeDispatchKeyGuardno_autocast(c10::DispatchKey::Autocast);
// The required at::kHalf argument is an optimistic initial guess.
autoexec_type=at::autocast::promote_type(at::kHalf,t0,t1);
returnmy_multiple_input_op(at::autocast::cached_cast(exec_type,t0),
at::autocast::cached_cast(exec_type,t1));
}
如果您的自定义操作启用了自动求导,您只需为已注册自动求导包装器的同名函数编写并注册一个自动类型转换包装器。例如,如果您想为自动求导部分中展示的 myadd
函数添加一个自动类型转换包装器,您只需这样做:
Tensormyadd_autocast(constTensor&self,constTensor&other){
c10::impl::ExcludeDispatchKeyGuardno_autocast(c10::DispatchKey::Autocast);
returnmyadd(at::autocast::cached_cast(<desireddtype>,self),
at::autocast::cached_cast(<desireddtype>,other));
}
TORCH_LIBRARY_IMPL(myops,Autocast,m){
m.impl("myadd",myadd_autocast);
}
无需进行额外操作来确保 backward
方法与自动类型转换(autocast)兼容。然而,您在自定义自动求导函数中定义的 backward
方法将以 forward
方法中自动类型转换设置的相同数据类型运行,因此您应选择一个同时适用于 forward
和 backward
方法的 <desired dtype>
。
批处理
批处理的张量允许您以逐样本的方式编写代码,然后在 vmap
调用下运行时自动进行批处理。编写批处理规则的 API 目前正在开发中,但一旦稳定下来,您可以通过在 Batched 分发键上注册内核来为您的操作符添加 vmap
支持。
Tracer
Tracer 调度键实现了在运行 torch.jit.trace
时将操作符的调用记录到跟踪中的功能。我们计划提供一个盒式回退机制,用于实现对任意操作的跟踪,请参阅 issue #41478 以跟踪进展。