• 教程 >
  • 在 C++ 中注册分派运算符
快捷方式

在 C++ 中注册分派运算符

创建于:2020 年 7 月 22 日 | 最后更新:2024 年 7 月 22 日 | 最后验证:2024 年 11 月 05 日

警告

本教程已在 PyTorch 2.4 中弃用。请参阅 PyTorch 自定义运算符 以获取关于使用自定义运算符扩展 PyTorch 的最新指南。

分派器是 PyTorch 的一个内部组件,负责确定当您调用诸如 torch::add 之类的函数时,实际应该运行什么代码。这可能很复杂,因为 PyTorch 运算需要处理许多“分层”在彼此之上的交叉关注点。以下是它处理的一些事项的示例:

  • 根据输入张量的设备,在运算符的 CPU 和 CUDA 实现之间切换。

  • 根据是否需要自动求导处理,在运算符的自动求导和后端实现之间切换。

  • 在必要时应用自动类型转换以实现自动混合精度。

  • 当运算符在 vmap 调用下运行时,应用批处理规则。

  • 如果您正在跟踪模型以进行导出,则跟踪运算的执行。

如果在您的 自定义运算符代码 中,您发现自己手动编写 if 语句来处理这些情况,则分派器 API 可以帮助组织您的代码。(相反,如果您的自定义运算符非常简单,仅用于 CPU 推理,则您可能不需要使用分派器,只需使用基本 API 即可。)

在本教程中,我们将描述如何构建自定义运算符注册以使用分派器来组织各种组件。我们将假设您熟悉如何 注册运算符 以及如何编写 自定义自动求导函数

定义模式和后端实现

分派器背后的总体原则是,它将运算符的实现划分为多个内核,每个内核实现特定分派键的功能,例如 CPU、CUDA。分派器确定在您调用运算符时优先级最高的分派键是什么(这通过查看张量参数以及一些线程局部状态来完成),并将控制权转移到该分派键的内核。最终效果是,当您调用运算符时,我们首先执行 Autograd 内核,然后我们根据传入张量的设备类型重新分派到后端内核。

让我们看一下实现此目的的各个部分。首先,我们必须定义相关运算符的模式。与简单的 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_cpumyops_cuda 动态库中。一般来说,您的注册结构将如下所示:

  1. 单个 TORCH_LIBRARY,在一个中心位置列出命名空间中的每个自定义运算符。

  2. 每个分派键一个 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 属性丢失的位置。

原地或视图运算

为了确保正确性和最佳性能,如果您的运算原地修改输入或返回与其中一个输入别名的张量,则应采取两个额外的步骤:

  1. 除了上面的 Autograd 内核之外,还注册一个 ADInplaceOrView 内核。此内核处理必要的簿记,以确保原地或视图运算的正确性。重要的是要注意,此 ADInplaceOrView 内核应仅与 autogradNotImplementedFallback 一起使用。

TORCH_LIBRARY_IMPL(myops, Autograd, m) {
  m.impl(op, autogradNotImplementedFallback());
}
TORCH_LIBRARY_IMPL(myops, ADInplaceOrView, m) {
  m.impl(op, autogradNotImplementedInplaceOrViewFallback());
}
  1. 上面注册的 AutogradADInplaceOrView boxed 内核依赖于其逻辑中的运算符模式信息。如果您的运算原地修改输入或返回与其中一个输入别名的张量,则务必确保您的模式正确反映了这一点。有关如何注释模式的更多信息,请参阅 此处

添加自动求导支持

此时,我们有一个同时具有 CPU 和 CUDA 实现的运算符。我们如何为其添加自动求导支持?正如您可能猜到的那样,我们将注册一个自动求导内核(类似于 自定义自动求导函数 教程中描述的内容)!但是,有一个转折:与 CPU 和 CUDA 内核不同,自动求导内核需要重新分派:它需要回调到分派器以获取推理内核,例如 CPU 或 CUDA 实现。

因此,在我们编写自动求导内核之前,让我们编写一个分派函数,该函数调用分派器以查找运算符的正确内核。此函数构成了您的运算符的公共 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() 中没有直接编写实现,而是:

  1. 使用 at::AutoNonVariableTypeMode RAII 保护器关闭自动求导处理,然后

  2. 调用分派函数 myadd 以回调到分派器。

如果没有 (1),您的调用将无限循环(并堆栈溢出),因为 myadd 会将您发送回此函数(因为优先级最高的分派键仍然是自动求导。)有了 (1),自动求导将从考虑的分派键集中排除,我们将转到下一个处理程序,即 CPU 和 CUDA。

我们现在可以像注册 CPU/CUDA 函数一样注册此函数:

TORCH_LIBRARY_IMPL(myops, Autograd, m) {
  m.impl("myadd", myadd_autograd);
}

注意

在此示例中,我们将内核注册到 Autograd,这会将其安装为所有后端的自动求导内核。您还可以通过使用相应的特定于后端的调度键来注册针对特定后端优化的内核 - 例如,AutogradCPUAutogradCUDA。要更详细地探索这些和其他分派键选项,请查看 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());
    }
  }
  ...
}

那么为什么要使用分派器呢?有几个原因:

  1. 它是去中心化的。您可以组装运算符的所有部分(CPU、CUDA、Autograd),而无需编写引用所有这些部分的一个中心化的 if 语句。重要的是,第三方可以注册其他方面的额外实现,而无需修补运算符的原始定义。我们将在 为新的后端扩展分派器 中详细讨论扩展分派器。

  2. 它支持比 CPU、CUDA 和 Autograd 更多的分派键。您可以在 c10/core/DispatchKey.h 中查看 PyTorch 当前实现的分派键的完整列表。这些分派键为运算符实现各种可选功能,如果您决定希望您的自定义运算符支持此功能,您只需为相应的键注册一个内核即可。

  3. 分派器实现了对 boxed 回退函数的支持,这些函数可以实现一次并应用于系统中的所有运算符。Boxed 回退函数可用于为分派键提供默认行为;如果您使用分派器来实现您的运算符,您还可以选择加入所有这些操作的回退。

以下是一些您可能需要为其定义运算符的特定分派键。

自动类型转换

自动类型转换分派键实现了对 自动混合精度 (AMP) 的支持。自动类型转换包装器内核通常在运行运算之前将传入的 float16float32 CUDA 张量转换为某些首选精度。例如,浮点 CUDA 张量上的 matmul 和卷积通常在 float16 中运行得更快并且使用更少的内存,而不会损害收敛性。自动类型转换包装器仅在 启用自动类型转换的上下文 中有效。

以下是假设的自定义 matmul 的自动类型转换包装器及其注册:

// 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 转换为 float16(如果 tensor 是 CUDA 且为 float32),否则,它将 tensor 保持不变(参见原生自动类型转换运算的 资格策略)。这确保了如果网络在任何 float16float32 CUDA 张量的混合上调用 mymatmul,则 mymatmulfloat16 中运行。同时,对非 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);
}

没有单独的技巧来使反向传播方法与自动类型转换兼容。但是,在您的自定义自动求导函数中定义的反向传播方法将以与自动类型转换为正向传播方法设置的相同 dtype 运行,因此您应该选择适合您的正向传播方法和反向传播方法的 <desired dtype>

批处理

批处理张量允许您以每个示例的方式编写代码,然后在 vmap 调用下运行时自动批处理它们。编写批处理规则的 API 当前正在开发中,但一旦稳定,您可以通过在 Batched 分派键处注册内核来为您的运算符添加对 vmap 的支持。

Tracer

当您运行 torch.jit.trace 时,Tracer 分派键实现了将运算符调用记录到跟踪中的支持。我们打算提供一个 boxed 回退函数,它将实现任意操作的跟踪,请参阅 issue #41478 以跟踪进度。

文档

访问 PyTorch 的全面开发者文档

查看文档

教程

获取面向初学者和高级开发者的深度教程

查看教程

资源

查找开发资源并获得问题解答

查看资源