扩展 PyTorch¶
在此说明中,我们将介绍扩展 torch.nn
、torch.autograd
、torch
以及编写自定义 C++ 扩展的方法。
扩展 torch.autograd
¶
向 autograd
添加操作需要为每个操作实现一个新的 Function
子类。回想一下,Functions 是 autograd
用于编码操作历史记录和计算梯度的。
本文档的第一部分重点介绍反向模式 AD,因为它是使用最广泛的功能。末尾部分讨论了正向模式 AD 的扩展。
何时使用¶
一般来说,如果您想在模型中执行不可微分的计算或依赖于非 PyTorch 库(例如 NumPy)的计算,但仍然希望您的操作与其他操作链在一起并与自动梯度引擎一起使用,请实现一个自定义函数。
在某些情况下,自定义函数还可以用于提高性能和内存使用率:如果您使用 C++ 扩展 实现正向和反向传递,则可以将它们包装在 Function
中以与自动梯度引擎交互。如果您想减少为反向传递保存的缓冲区数量,则可以使用自定义函数将操作组合在一起。
何时不使用¶
如果您已经可以用 PyTorch 的内置操作来编写函数,那么它的反向图(很可能)已经可以由自动梯度记录。在这种情况下,您无需自己实现反向函数。考虑使用一个普通的 Python 函数。
如果您需要维护状态,即,可训练参数,您应该(也)使用自定义模块。有关扩展 torch.nn
的更多信息,请参阅下面的部分。
如何使用¶
执行以下步骤:1. 子类化 Function
并实现 forward()
、(可选)setup_context()
和 backward()
方法。2. 在 ctx 参数上调用适当的方法。3. 声明您的函数是否支持 双向反向传播。4. 使用 gradcheck 验证您的梯度是否正确。
步骤 1:在对 Function
进行子类化后,您需要定义 3 个方法
forward()
是执行操作的代码。它可以接受任意数量的参数,其中一些参数是可选的,如果你指定了默认值。这里接受所有类型的 Python 对象。Tensor
跟踪历史记录的参数(即,带有requires_grad=True
)将在调用前转换为不跟踪历史记录的参数,并且它们的使用将注册到图中。请注意,此逻辑不会遍历列表/字典/任何其他数据结构,并且只会考虑作为调用直接参数的张量。你可以返回单个Tensor
输出,或者如果有多个输出,则返回张量的tuple
。此外,请参阅Function
的文档,以找到仅可从forward()
调用的有用方法的描述。setup_context()
(可选)。可以编写一个接受ctx
对象的“组合”forward()
,或者(从 PyTorch 2.0 开始)编写一个不接受ctx
的单独forward()
和一个setup_context()
方法,其中发生ctx
修改。forward()
应该具有计算,而setup_context()
应该只负责ctx
修改(而不进行任何计算)。通常,单独的forward()
和setup_context()
更接近 PyTorch 本机操作的工作方式,因此与各种 PyTorch 子系统更具可组合性。有关更多详细信息,请参阅 组合或单独的 forward() 和 setup_context()。backward()
(或vjp()
)定义梯度公式。它将被赋予与输出一样多的Tensor
参数,每个参数都表示相对于该输出的梯度。重要的是切勿就地修改这些参数。它应该返回与输入一样多的张量,每个张量都包含相对于其对应输入的梯度。如果您的输入不需要梯度(needs_input_grad
是一个布尔元组,表示每个输入是否需要梯度计算),或者是非Tensor
对象,则可以返回python:None
。此外,如果您对forward()
有可选参数,则可以返回比输入更多的梯度,只要它们都是None
。
步骤 2:您有责任正确使用 ctx
中的函数,以确保新的 Function
与自动梯度引擎正常协作。
save_for_backward()
必须用于保存要在反向传递中使用的任何张量。非张量应直接存储在 ctx 上。如果既不是输入也不是输出的张量被保存用于反向传递,则您的Function
可能不支持二次反向传递(请参见步骤 3)。mark_dirty()
必须用于标记由前向函数就地修改的任何输入。mark_non_differentiable()
必须用于告诉引擎输出是否不可微。默认情况下,所有可微类型的输出张量都将被设置为需要梯度。不可微类型的张量(即整数类型)永远不会被标记为需要梯度。set_materialize_grads()
可用于告知自动梯度引擎在输出不依赖于输入的情况下优化梯度计算,方法是不实现传递给反向函数的梯度张量。也就是说,如果设置为 False,Python 中的 None 对象或 C++ 中的“未定义张量”(张量 x,其中 x.defined() 为 False)在调用反向之前不会被转换为填充有零的张量,因此你的代码需要将此类对象当作填充有零的张量来处理。此设置的默认值为 True。
步骤 3:如果你的 Function
不支持二次反向,你应该通过使用 once_differentiable()
装饰反向来明确声明这一点。使用此装饰器,尝试通过你的函数执行二次反向将产生一个错误。有关二次反向的更多信息,请参阅我们的二次反向教程。
步骤 4:建议你使用 torch.autograd.gradcheck()
来检查你的反向函数是否正确计算正向的梯度,方法是使用你的反向函数计算雅可比矩阵,并逐元素将该值与使用有限差分数值计算的雅可比矩阵进行比较。
示例¶
下面你可以找到一个 Linear
函数的代码,并添加了额外的注释
# Inherit from Function
class LinearFunction(Function):
# Note that forward, setup_context, and backward are @staticmethods
@staticmethod
def forward(input, weight, bias):
output = input.mm(weight.t())
if bias is not None:
output += bias.unsqueeze(0).expand_as(output)
return output
@staticmethod
# inputs is a Tuple of all of the inputs passed to forward.
# output is the output of the forward().
def setup_context(ctx, inputs, output):
input, weight, bias = inputs
ctx.save_for_backward(input, weight, bias)
# This function has only a single output, so it gets only one gradient
@staticmethod
def backward(ctx, grad_output):
# This is a pattern that is very convenient - at the top of backward
# unpack saved_tensors and initialize all gradients w.r.t. inputs to
# None. Thanks to the fact that additional trailing Nones are
# ignored, the return statement is simple even when the function has
# optional inputs.
input, weight, bias = ctx.saved_tensors
grad_input = grad_weight = grad_bias = None
# These needs_input_grad checks are optional and there only to
# improve efficiency. If you want to make your code simpler, you can
# skip them. Returning gradients for inputs that don't require it is
# not an error.
if ctx.needs_input_grad[0]:
grad_input = grad_output.mm(weight)
if ctx.needs_input_grad[1]:
grad_weight = grad_output.t().mm(input)
if bias is not None and ctx.needs_input_grad[2]:
grad_bias = grad_output.sum(0)
return grad_input, grad_weight, grad_bias
现在,为了更轻松地使用这些自定义操作,我们建议别名它们或将它们包装在函数中。包装在函数中让我们能够支持默认参数和关键字参数
# Option 1: alias
linear = LinearFunction.apply
# Option 2: wrap in a function, to support default args and keyword args.
def linear(input, weight, bias=None):
return LinearFunction.apply(input, weight, bias)
在此,我们给出了一个由非张量参数参数化的函数的附加示例
class MulConstant(Function):
@staticmethod
def forward(tensor, constant):
return tensor * constant
@staticmethod
def setup_context(ctx, inputs, output):
# ctx is a context object that can be used to stash information
# for backward computation
tensor, constant = inputs
ctx.constant = constant
@staticmethod
def backward(ctx, grad_output):
# We return as many input gradients as there were arguments.
# Gradients of non-Tensor arguments to forward must be None.
return grad_output * ctx.constant, None
在此,我们通过调用 set_materialize_grads(False) 优化上述示例
class MulConstant(Function):
@staticmethod
def forward(tensor, constant):
return tensor * constant
@staticmethod
def setup_context(ctx, inputs, output):
tensor, constant = inputs
ctx.set_materialize_grads(False)
ctx.constant = constant
@staticmethod
def backward(ctx, grad_output):
# Here we must handle None grad_output tensor. In this case we
# can skip unnecessary computations and just return None.
if grad_output is None:
return None, None
# We return as many input gradients as there were arguments.
# Gradients of non-Tensor arguments to forward must be None.
return grad_output * ctx.constant, None
如果您需要保存计算出的任何“中间”张量 forward()
,则必须将它们作为输出返回,或组合 forward
和 setup_context()
(请参阅 组合或分离 forward() 和 setup_context())。请注意,这意味着如果您希望梯度流经这些中间值,则需要为它们定义梯度公式(另请参阅 双向反向传播教程)
class MyCube(torch.autograd.Function):
@staticmethod
def forward(x):
# We wish to save dx for backward. In order to do so, it must
# be returned as an output.
dx = 3 * x ** 2
result = x ** 3
return result, dx
@staticmethod
def setup_context(ctx, inputs, output):
x, = inputs
result, dx = output
ctx.save_for_backward(x, dx)
@staticmethod
def backward(ctx, grad_output, grad_dx):
x, dx = ctx.saved_tensors
# In order for the autograd.Function to work with higher-order
# gradients, we must add the gradient contribution of `dx`,
# which is grad_dx * 6 * x.
result = grad_output * dx + grad_dx * 6 * x
return result
# Wrap MyCube in a function so that it is clearer what the output is
def my_cube(x):
result, dx = MyCube.apply(x)
return result
注意
输入 backward
,即 grad_output
,也可以是跟踪历史记录的张量。因此,如果 backward
是使用可微分操作实现的(例如,调用另一个自定义 Function
),则高阶导数将起作用。在这种情况下,使用 save_for_backward
保存的张量也可以在反向传播中使用,并具有反向流动的梯度,但保存在 ctx
中的张量不会有反向流动的梯度。如果您需要为保存在 ctx
中的张量反向流动梯度,则应将其设为自定义 Function
的输出,并使用 save_for_backward
保存它。
您可能希望检查您实现的反向传播方法是否实际计算了函数的导数。可以通过与使用小有限差分的数值近似值进行比较来实现
from torch.autograd import gradcheck
# gradcheck takes a tuple of tensors as input, check if your gradient
# evaluated with these tensors are close enough to numerical
# approximations and returns True if they all verify this condition.
input = (torch.randn(20,20,dtype=torch.double,requires_grad=True), torch.randn(30,20,dtype=torch.double,requires_grad=True))
test = gradcheck(linear, input, eps=1e-6, atol=1e-4)
print(test)
有关有限差分梯度比较的更多详细信息,请参阅 数值梯度检查。如果您的函数用于高阶导数(对反向传播求导),则可以使用来自同一软件包的 gradgradcheck
函数检查高阶导数。
组合或分离 forward()
和 setup_context()
¶
定义 Function
的主要方法有两种。要么
我们推荐第二种选择(单独的 forward()
和 setup_context()
),因为这更接近于 PyTorch 本机操作的实现方式,并且它与 torch.func
转换相组合。但是,我们计划在未来支持这两种方法;将 forward()
与 setup_context()
请参阅上一节以了解如何使用单独的 forward()
和 setup_context()
定义 Function
。
以下是如何定义一个 Function
的示例,其中结合了 forward()
和 setup_context()
class LinearFunction(Function):
@staticmethod
# ctx is the first argument to forward
def forward(ctx, input, weight, bias=None):
# The forward pass can use ctx.
ctx.save_for_backward(input, weight, bias)
output = input.mm(weight.t())
if bias is not None:
output += bias.unsqueeze(0).expand_as(output)
return output
@staticmethod
def backward(ctx, grad_output):
input, weight, bias = ctx.saved_tensors
grad_input = grad_weight = grad_bias = None
if ctx.needs_input_grad[0]:
grad_input = grad_output.mm(weight)
if ctx.needs_input_grad[1]:
grad_weight = grad_output.t().mm(input)
if bias is not None and ctx.needs_input_grad[2]:
grad_bias = grad_output.sum(0)
return grad_input, grad_weight, grad_bias
前向模式 AD¶
覆盖前向模式 AD 公式的 API 与一些不同的细微差别非常相似。您可以实现 jvp()
函数。
它将被赋予与输入一样多的 Tensor
参数,每个参数都表示相对于该输入的梯度。它应该返回与输出一样多的张量,每个张量都包含相对于其对应输出的梯度。 jvp()
将在 forward()
方法之后立即调用,在 apply()
返回之前。
jvp()
与 backward()
函数有一些细微差别
您可以使用 ctx 从
forward()
传递任何数据到jvp()
函数。如果backward()
不需要该状态,您可以在jvp()
函数的末尾执行del ctx.foo
来显式释放它。必须对
jvp()
的实现进行反向微分,或显式检查给定的前向模式梯度中没有一个设置了requires_grad
。jvp()
函数必须与forward()
的视图/就地行为相匹配。例如,如果第i
个输入就地修改,则必须就地更新第i
个梯度。类似地,如果第j
个输出是第k
个输入的视图。那么返回的第j
个输出梯度必须是给定的第k
个输入梯度的视图。由于用户无法指定需要计算哪个梯度,因此
jvp()
函数应始终计算所有输出的梯度。前向模式梯度尊重
set_materialize_grads()
设置的标志,并且在禁用此标志时,你可以获得 None 输入梯度。
torch.func
变换和/或 torch.vmap()
¶
有关详细信息,请参阅 使用 autograd.Function 扩展 torch.func。
扩展 torch.nn
¶
nn
导出了两种类型的接口 - 模块及其函数版本。你可以通过这两种方式进行扩展,但我们建议对所有类型的层使用模块,这些层包含任何参数或缓冲区,并建议对激活函数、池化等无参数操作使用函数形式。
在上面一节中已经完全介绍了如何添加操作的函数版本。
添加 Module
¶
由于 nn
大量利用 autograd
,添加新的 Module
需要实现一个执行操作且可以计算梯度的 Function
。从现在开始,我们假设要实现一个 Linear
模块,并且我们已将函数实现为上面清单中的函数。添加此函数所需代码非常少。现在,需要实现两个函数
下面是实现 Linear
模块的方法
class Linear(nn.Module):
def __init__(self, input_features, output_features, bias=True):
super().__init__()
self.input_features = input_features
self.output_features = output_features
# nn.Parameter is a special kind of Tensor, that will get
# automatically registered as Module's parameter once it's assigned
# as an attribute. Parameters and buffers need to be registered, or
# they won't appear in .parameters() (doesn't apply to buffers), and
# won't be converted when e.g. .cuda() is called. You can use
# .register_buffer() to register buffers.
# nn.Parameters require gradients by default.
self.weight = nn.Parameter(torch.empty(output_features, input_features))
if bias:
self.bias = nn.Parameter(torch.empty(output_features))
else:
# You should always register all possible parameters, but the
# optional ones can be None if you want.
self.register_parameter('bias', None)
# Not a very smart way to initialize weights
nn.init.uniform_(self.weight, -0.1, 0.1)
if self.bias is not None:
nn.init.uniform_(self.bias, -0.1, 0.1)
def forward(self, input):
# See the autograd section for explanation of what happens here.
return LinearFunction.apply(input, self.weight, self.bias)
def extra_repr(self):
# (Optional)Set the extra information about this module. You can test
# it by printing an object of this class.
return 'input_features={}, output_features={}, bias={}'.format(
self.input_features, self.output_features, self.bias is not None
)
扩展 torch
Python API¶
通过定义一个具有与 Tensor
匹配的方法的自定义类,您可以创建模拟 Tensor
的自定义类型。但如果您想将这些类型传递给诸如 torch.add()
之类的函数,该怎么办?这些函数位于接受 Tensor
操作数的顶级 torch
命名空间中?
如果您的自定义 Python 类型定义了一个名为 __torch_function__
的方法,则当您的自定义类的实例传递给 torch
命名空间中的函数时,PyTorch 将调用您的 __torch_function__
实现。这使得可以为 torch
命名空间中的任何函数定义自定义实现,您的 __torch_function__
实现可以调用这些函数,从而允许您的用户将您的自定义类型与他们已经为 Tensor
编写的现有 PyTorch 工作流结合使用。这适用于与 Tensor
无关的“鸭”类型以及 Tensor
的用户定义子类。
使用类似 Tensor
的类型扩展 torch
¶
为了具体说明这一点,我们从一个简单的示例开始,该示例说明了 API 调度机制。我们将创建一个自定义类型,该类型表示 2D 标量张量,由阶数 N
和对角线条目 value
处的参数化
class ScalarTensor(object):
def __init__(self, N, value):
self._N = N
self._value = value
def __repr__(self):
return "ScalarTensor(N={}, value={})".format(self._N, self._value)
def tensor(self):
return self._value * torch.eye(self._N)
设计的第一次迭代并不是很有用。ScalarTensor
的主要功能是提供比基本张量类中更紧凑的标量张量字符串表示形式
>>> d = ScalarTensor(5, 2)
>>> d
ScalarTensor(N=5, value=2)
>>> d.tensor()
tensor([[2., 0., 0., 0., 0.],
[0., 2., 0., 0., 0.],
[0., 0., 2., 0., 0.],
[0., 0., 0., 2., 0.],
[0., 0., 0., 0., 2.]])
如果我们尝试将此对象与 torch
API 一起使用,我们将遇到问题
>>> import torch
>>> torch.mean(d)
TypeError: mean(): argument 'input' (position 1) must be Tensor, not ScalarTensor
向 ScalarTensor
添加 __torch_function__
实现可以使上述操作成功。让我们重新执行我们的实现,这次添加一个 __torch_function__
实现
HANDLED_FUNCTIONS = {}
class ScalarTensor(object):
def __init__(self, N, value):
self._N = N
self._value = value
def __repr__(self):
return "ScalarTensor(N={}, value={})".format(self._N, self._value)
def tensor(self):
return self._value * torch.eye(self._N)
@classmethod
def __torch_function__(cls, func, types, args=(), kwargs=None):
if kwargs is None:
kwargs = {}
if func not in HANDLED_FUNCTIONS or not all(
issubclass(t, (torch.Tensor, ScalarTensor))
for t in types
):
return NotImplemented
return HANDLED_FUNCTIONS[func](*args, **kwargs)
__torch_function__
方法采用四个参数:func
,对被覆盖的 torch API 函数的引用,types
,实现 __torch_function__
的类似张量类型的列表,args
,传递给函数的参数元组,以及 kwargs
,传递给函数的关键字参数字典。它使用名为 HANDLED_FUNCTIONS
的全局调度表来存储自定义实现。此字典的键是 torch
命名空间中的函数,值是 ScalarTensor
的实现。
注意
使用全局调度表并不是 __torch_function__
API 的强制部分,它只是一种用于构建覆盖实现的有用设计模式。
此类定义不足以使 torch.mean
在我们传递 ScalarTensor
时执行正确操作——我们还需要为 ScalarTensor
操作数定义 torch.mean
的实现,并将实现添加到 HANDLED_FUNCTIONS
调度表字典。执行此操作的一种方法是定义一个装饰器
import functools
def implements(torch_function):
"""Register a torch function override for ScalarTensor"""
def decorator(func):
functools.update_wrapper(func, torch_function)
HANDLED_FUNCTIONS[torch_function] = func
return func
return decorator
它可以应用于我们覆盖的实现
@implements(torch.mean)
def mean(input):
return float(input._value) / input._N
通过此更改,我们现在可以使用 ScalarTensor
来使用 torch.mean
>>> d = ScalarTensor(5, 2)
>>> torch.mean(d)
0.4
当然,torch.mean
是最简单的可覆盖函数的一个示例,因为它只接受一个操作数。我们可以使用相同的机制来覆盖一个接受多个操作数的函数,其中任何一个操作数都可能是定义了 __torch_function__
的张量或类张量,例如 torch.add()
def ensure_tensor(data):
if isinstance(data, ScalarTensor):
return data.tensor()
return torch.as_tensor(data)
@implements(torch.add)
def add(input, other):
try:
if input._N == other._N:
return ScalarTensor(input._N, input._value + other._value)
else:
raise ValueError("Shape mismatch!")
except AttributeError:
return torch.add(ensure_tensor(input), ensure_tensor(other))
当两个操作数都是 ScalarTensor
实例时,此版本具有一个快速路径,并且还有一个较慢的路径,当任一操作数不是 ScalarTensor
时,该路径会降级为将数据转换为张量。当任一操作数是 ScalarTensor
或常规 Tensor
时,这会正确地覆盖函数
>>> s = ScalarTensor(2, 2)
>>> torch.add(s, s)
ScalarTensor(N=2, value=4)
>>> t = torch.tensor([[1, 1,], [1, 1]])
>>> torch.add(s, t)
tensor([[3., 1.],
[1., 3.]])
请注意,我们的 add
实现不接受 alpha
或 out
作为关键字参数,而 torch.add()
接受
>>> torch.add(s, s, alpha=2)
TypeError: add() got an unexpected keyword argument 'alpha'
为了提高速度和灵活性,__torch_function__
分发机制不会检查覆盖函数的签名是否与 torch
API 中被覆盖函数的签名相匹配。对于某些应用程序来说,忽略可选参数是可以的,但是为了确保与 Tensor
完全兼容,用户实现的 torch API 函数应注意完全模拟被覆盖函数的 API。
在 torch
API 中没有显式覆盖的函数将从 __torch_function__
返回 NotImplemented
。如果所有在它们上面定义了 __torch_function__
的操作数都返回 NotImplemented
,PyTorch 将引发 TypeError
。这意味着大多数时候,对于没有针对某个类型进行显式覆盖的操作,当传递此类类型的实例时,将引发 TypeError
>>> torch.mul(s, 3)
TypeError: no implementation found for 'torch.mul' on types that
implement __torch_function__: [ScalarTensor]
在实践中,这意味着如果你希望使用 __torch_function__
实现沿这些思路进行覆盖,则需要显式实现完整的 torch
API 或对你用例有用的 API 的整个子集。这可能是一个艰巨的任务,因为完整的 torch
API 非常广泛。
另一种选择是对于未处理的操作不返回 NotImplemented
,而是当没有可用的覆盖时将 Tensor
传递给原始 torch
函数。例如,如果我们将 ScalarTensor
的 __torch_function__
实现更改为以下内容
@classmethod
def __torch_function__(cls, func, types, args=(), kwargs=None):
if kwargs is None:
kwargs = {}
if func not in HANDLED_FUNCTIONS or not all(
issubclass(t, (torch.Tensor, ScalarTensor))
for t in types
):
args = [a.tensor() if hasattr(a, 'tensor') else a for a in args]
return func(*args, **kwargs)
return HANDLED_FUNCTIONS[func](*args, **kwargs)
然后 torch.mul()
将正常工作,尽管返回类型始终是 Tensor
而不是 ScalarTensor
,即使两个操作数都是 ScalarTensor
实例
>>> s = ScalarTensor(2, 2)
>>> torch.mul(s, s)
tensor([[4., 0.],
[0., 4.]])
另请参阅下面的 MetadataTensor
示例,了解此模式的另一种变化,但始终返回 MetadataTensor
以通过 torch
API 中的操作传播元数据。
协议旨在全面覆盖 API,部分覆盖可能会导致不良结果,特别是某些函数引发 __torch_function__
。对于子类来说尤其如此,其中 TypeError
torch.add
、torch.Tensor.__add__
和 torch.Tensor.add
三者都必须覆盖,即使它们返回完全相同的结果。如果不这样做,还可能导致无限递归。如果需要从
子类实现函数,则必须在实现中使用 torch.Tensor
。super().__torch_function__
子类化 torch.Tensor
¶
torch.Tensor
从 1.7.0 版开始,应用于
子类的 torch.Tensor
上的方法和公共 torch.Tensor
命名空间中的函数将返回子类实例,而不是 torch.*
实例torch.Tensor
>>> class SubTensor(torch.Tensor):
... pass
>>> type(torch.add(SubTensor([0]), SubTensor([1]))).__name__
'SubTensor'
>>> type(torch.add(SubTensor([0]), torch.tensor([1]))).__name__
'SubTensor'
如果存在多个子类,则默认情况下将选择层次结构中最底层的子类。如果没有唯一的方法来确定这种情况,则会引发 TypeError
>>> type(torch.add(SubTensor2([0]), SubTensor([1]))).__name__
'SubTensor2'
>>> type(torch.add(SubTensor2([0]), torch.tensor([1]))).__name__
'SubTensor2'
>>> torch.add(SubTensor([0]), OtherSubTensor([1]))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: no implementation found for 'torch.add' on types that implement __torch_function__: [SubTensor, OtherSubTensor]
如果希望对所有张量方法进行全局覆盖,可以使用
。以下是一个记录所有函数/方法调用的示例__torch_function__
class LoggingTensor(torch.Tensor):
@classmethod
def __torch_function__(cls, func, types, args=(), kwargs=None):
# NOTE: Logging calls Tensor.__repr__, so we can't log __repr__ without infinite recursion
if func is not torch.Tensor.__repr__:
logging.info(f"func: {func.__name__}, args: {args!r}, kwargs: {kwargs!r}")
if kwargs is None:
kwargs = {}
return super().__torch_function__(func, types, args, kwargs)
但是,如果希望覆盖张量子类上的方法,则可以通过直接覆盖方法(为子类定义方法)或使用
并与 __torch_function__
匹配来实现。func
在
中,对于子类,应始终小心地调用 __torch_function__
,而不是直接调用 super().__torch_function__(func, ...)
,就像 1.7.0 版之前的情况一样。如果不这样做,可能会导致 func
递归回 func
,从而导致无限递归。__torch_function__
使用 Tensor
包装器类型扩展 torch
¶
另一个有用的情况是包装 Tensor
的类型,作为属性或通过子类化。下面我们实现这种类型的特殊情况,即 MetadataTensor
,它将元数据字典附加到 Tensor
,该字典通过 torch
操作传播。由于这是对完整 torch
API 的通用包装,因此我们无需单独实现每个覆盖,这样我们就可以使 __torch_function__
实现对允许的操作更加宽容
class MetadataTensor(object):
def __init__(self, data, metadata=None, **kwargs):
self._t = torch.as_tensor(data, **kwargs)
self._metadata = metadata
def __repr__(self):
return "Metadata:\n{}\n\ndata:\n{}".format(self._metadata, self._t)
@classmethod
def __torch_function__(cls, func, types, args=(), kwargs=None):
if kwargs is None:
kwargs = {}
metadatas = tuple(a._metadata for a in args if hasattr(a, '_metadata'))
args = [getattr(a, '_t', a) for a in args]
assert len(metadatas) > 0
ret = func(*args, **kwargs)
return MetadataTensor(ret, metadata=metadatas[0])
此简单实现不一定适用于 torch
API 中的每个函数,但足以捕获大多数常见操作
>>> metadata = {'owner': 'Ministry of Silly Walks'}
>>> m = MetadataTensor([[1, 2], [3, 4]], metadata=metadata)
>>> t = torch.tensor([[1, 2], [1, 2]])
>>> torch.add(t, m)
Metadata:
{'owner': 'Ministry of Silly Walks'}
data:
tensor([[2, 4],
[4, 6]])
>>> torch.mul(t, m)
Metadata:
{'owner': 'Ministry of Silly Walks'}
data:
tensor([[1, 4],
[3, 8]])
定义 __torch_function__
的多种类型的操作¶
可以将 torch API 与具有 __torch_function__
实现的多个不同类型一起使用,但必须特别小心。在这种情况下,规则是
调度操作收集每个操作数的
__torch_function__
的所有不同实现,并按顺序调用它们:子类在超类之前,否则在操作器表达式中从左到右。如果返回的值不是
NotImplemented
,则该值将作为结果返回。实现可以通过返回NotImplemented
来注册它们未实现操作。如果所有
__torch_function__
实现都返回NotImplemented
,PyTorch 会引发TypeError
。
PyTorch API 重写的测试覆盖率¶
实现 __torch_function__
的一个麻烦方面是,如果某些操作有重写而另一些操作没有,用户充其量会看到不一致的体验,最坏的情况是当他们使用没有重写的函数时,会在运行时看到引发错误。为了简化此过程,PyTorch 提供了一个面向开发人员的 API,用于确保对 __torch_function__
重写的完全支持。此 API 是私有的,将来可能会在不发出警告的情况下进行更改。
首先,要获取所有可重写函数的列表,请使用 torch.overrides._get_overridable_functions
。这会返回一个字典,其键是 PyTorch
Python API 中的命名空间,其值是可以重写的该命名空间中的函数列表。例如,让我们打印 torch.nn.functional
中可以重写的首 5 个函数的名称
>>> from torch.overrides import get_overridable_functions
>>> func_dict = get_overridable_functions()
>>> nn_funcs = func_dict[torch.nn.functional]
>>> print([f.__name__ for f in nn_funcs[:5])
['adaptive_avg_pool1d', 'adaptive_avg_pool2d', 'adaptive_avg_pool3d',
'adaptive_max_pool1d', 'adaptive_max_pool1d_with_indices']
此函数列表可以迭代所有可覆盖函数,但实际上这不足以编写所有这些函数的测试,而无需费力地手动复制每个函数的签名以进行每个测试。为了简化此过程,torch.overrides._get_testing_overrides
函数返回一个字典,将 PyTorch
API 中的可覆盖函数映射到与原始函数具有相同签名的虚拟 lambda 函数,但无条件返回 -1。这些函数最适合与 inspect
一起使用,以分析原始 PyTorch
函数的函数签名
>>> import inspect
>>> from torch.overrides import get_testing_overrides
>>> override_dict = get_testing_overrides()
>>> dummy_add = override_dict[torch.add]
>>> inspect.signature(dummy_add)
<Signature (input, other, out=None)>
最后,torch.overrides.get_ignored_functions
返回一个函数元组,该元组明确不能被 __torch_function__
覆盖。此列表可用于确认 get_overridable_functions
返回的字典中不存在的函数不能被覆盖。
扩展 torch
原生 API¶
虽然 __torch_function__
允许有效地扩展 PyTorch 纯 Python 组件的行为,但它不允许扩展用 C++ 实现的 PyTorch 部分。为此,Tensor
子类还可以定义 __torch_dispatch__
,它将能够在 C++ 级别覆盖行为。
要有效使用此功能,了解 PyTorch 的原生部分是如何实现的非常重要。那里最重要的组件是我们称之为“分派器”的组件(最好的描述可以在此 博客文章 中找到,即使它略有陈旧)。正如其名称所暗示的,它负责为特定函数调用调用正确的后端函数。例如,当调用 torch.add(a, b)
时,分派器将检查两个参数,找出应为该特定调用使用哪个“功能”(自动梯度、自动转换、功能化等)和哪个“后端”(CPU、CUDA、MPS 等),最后调用所有正确的内核。内核执行的一件非常常见的事情是“重新分派”。例如,当在具有自动转换的 GPU 上运行神经网络时,第一个调用将是自动转换内核,它将处理任何潜在的自动转换逻辑并重新分派。下一行功能将是自动梯度,它将正确创建自动梯度图,然后重新分派。最后,我们到达 CUDA 的后端内核,它将启动正确的 CUDA 内核并返回最终结果。在退出时,自动梯度会将图附加到输出,最后,自动转换有机会在退出时执行任何需要的更新。
分派器的一种配置是调用所有这些功能和后端键的顺序。最新列表及其顺序可以在 DispatchKey.h
中的 DispatchKey
枚举中找到。为了扩展 torch,本次讨论中排序的重要子集是
vmap -> Autocast -> Autograd -> ZeroTensor -> Neg/Conj -> Functionalize -> Python -> Backends
本次讨论中最重要的键是 Python
,因为定义了 __torch_dispatch__
方法的每个张量子类都将调用此功能。正是从那里调用用户定义的方法,并且可以在那里任意覆盖行为。从那里,再次调用提供的 func
将执行“重新分派”。
此实现的一些重要含义是
此代码运行在“所有功能之下”。因此,它只负责像普通后端一样生成每个张量的输出值(并且可以而且应该忽略自动梯度、自动转换等所有高级功能)。
如果任何高级功能在不重新分派的情况下实现给定函数,它将永远不会达到
Python
键,因此__torch_dispatch__
回调将永远不会被触发。这尤其发生在 CompositeImplicitAutograd 函数中,该函数在 Autograd 级别求值,而不重新分派。这是因为 CompositeImplicitAutograd 函数通过隐式调用其他原生操作来指定其自动微分公式,因此在 Autograd 级别,该函数分解为其原生操作,并对其进行求值。在回调到 Python 和包装结果时,使用与常规 PyTorch Python/C++ 绑定相同的转换。特别是,某些对象无法在 Python 中表示,需要特殊处理(例如未定义的张量变为 None)。
我们的原生函数被延迟填充为
torch.ops.{namespace}.{func_name}.{overload_name}
作为可调用的 Python 对象,以便于从 Python 中轻松与它们交互。提供给__torch_dispatch__
的func
对象始终是此名称空间中的一个条目。此名称空间可用于直接调用原生操作,并绕过通常的 Python API 和绑定代码。
以类似于 __torch_function__
能够插入到所有 torch 的 Python API 和张量方法中的方式,__torch_dispatch__
能够拦截对 aten 原生 API 的所有调用。请注意,在进入调度程序之前,张量上的所有方法都转换为函数调用,因此此处将显示为函数调用:torch.add(a, 2)
和 a + 2
将导致完全相同的 aten 调用。这些函数大多数在 native_functions.yaml
中定义,该文件指定了这些函数的属性及其后端实现。然后通过代码生成自动注册它们的实现以及指定的功能。一些更奇特的函数或特性也注册在 C++ 代码库中的其他位置或用户定义的 C++ 扩展中。
还可以使用 torch.library
添加新原生函数。此 Python 特性允许定义和/或向原生函数添加新实现。这可用于添加缺失的内核、替换现有的内核或定义全新的原生函数。
您可以在 subclass zoo 仓库中找到许多基于 __torch_dispatch__
的子类的示例。
使用模式扩展所有 torch
API¶
不幸的是,有些函数不接受张量输入。这意味着上面描述的子类方法无法用来覆盖所有 PyTorch 函数的行为。此外,如果用例要求拦截每个函数调用,将每个张量更改为子类可能会过于激进。
为了解决此用例,我们引入了“模式”的概念。它们存在于 __torch_function__
和 __torch_dispatch__
覆盖中,分别通过继承 torch.overrides.TorchFunctionMode
和 torch.utils._python_dispatch.TorchDispatchMode
创建,并用作上下文管理器。
为了简化其与子类和其他模式交互方式的描述,每当进入模式的上下文管理器时,每个函数的行为都好像在参数列表的开头有一个额外的张量参数,模式作为子类。这意味着特别是所有模式处理程序都将在任何子类处理程序之前被调用,并且对应于内部上下文管理器的模式将始终首先运行。
同样重要的是要注意,在给定的模式处理程序中,此特定模式被禁用,并且可以通过执行 with self:
手动重新启用。
以下是一个显示每种类型日志模式的示例
import torch
from torch.overrides import TorchFunctionMode, resolve_name
from torch.utils._python_dispatch import TorchDispatchMode
class FunctionLog(TorchFunctionMode):
def __torch_function__(self, func, types, args, kwargs=None):
print(f"Function Log: {resolve_name(func)}(*{args}, **{kwargs})")
return func(*args, **(kwargs or {}))
class DispatchLog(TorchDispatchMode):
def __torch_dispatch__(self, func, types, args, kwargs=None):
print(f"Dispatch Log: {func}(*{args}, **{kwargs})")
return func(*args, **(kwargs or {}))
def f():
a = torch.rand(10, requires_grad=True)
b = a * 2
b.sum().backward()
print("TorchFunctionMode logging:")
with FunctionLog():
f()
print("TorchDispatchMode logging:")
with DispatchLog():
f()
它打印以下内容,并带有额外的注释
TorchFunctionMode logging:
Function Log: torch.rand(*(10,), **{'requires_grad': True})
Function Log: torch.Tensor.mul(*(tensor([0.7164, 0.9897, 0.1745, 0.9336, 0.4287, 0.7989, 0.2169, 0.7474, 0.5624,
0.5970], requires_grad=True), 2), **None)
Function Log: torch.Tensor.sum(*(tensor([1.4328, 1.9794, 0.3490, 1.8671, 0.8573, 1.5977, 0.4338, 1.4948, 1.1249,
1.1939], grad_fn=<MulBackward0>),), **None)
# Note that at the python level, we only see the call to backward but not what happens in the autograd engine.
Function Log: torch.Tensor.backward(*(tensor(12.3307, grad_fn=<SumBackward0>),), **{'gradient': None, 'retain_graph': None, 'create_graph': False, 'inputs': None})
TorchDispatchMode logging:
# Here the requires_grad flag from autograd is removed while default arguments were populated.
Dispatch Log: aten.rand.default(*([10],), **{'device': device(type='cpu'), 'pin_memory': False})
Dispatch Log: aten.mul.Tensor(*(tensor([0.2151, 0.6018, 0.8415, 0.9060, 0.2974, 0.7708, 0.6668, 0.0352, 0.7948,
0.6023], requires_grad=True), 2), **{})
Dispatch Log: aten.sum.default(*(tensor([0.4303, 1.2036, 1.6831, 1.8120, 0.5949, 1.5416, 1.3335, 0.0705, 1.5897,
1.2046], grad_fn=<MulBackward0>),), **{})
# Here we don't see the call to backward itself, but its constituents. Starting here with the factory function that creates the initial gradient.
Dispatch Log: aten.ones_like.default(*(tensor(11.4637, grad_fn=<SumBackward0>),), **{'pin_memory': False, 'memory_format': torch.preserve_format})
# This is the backward of the sum
Dispatch Log: aten.expand.default(*(tensor(1.), [10]), **{})
Dispatch Log: aten.mul.Tensor(*(tensor([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.]), 2), **{})
Dispatch Log: aten.detach.default(*(tensor([2., 2., 2., 2., 2., 2., 2., 2., 2., 2.]),), **{})
Dispatch Log: aten.detach.default(*(tensor([2., 2., 2., 2., 2., 2., 2., 2., 2., 2.]),), **{})