已经学习PyTorch一段时间了,不得不说对于其中很多知识都存在不解,最近淘到一篇讨论PyTorch内部机制的文章,觉得很是不错,留下来好好看:
万字综述,核心开发者全面解读PyTorch内部机制
【本文经机器之心(微信公众号:almosthuman2014)授权转载,禁止二次转载
作者:Edward Z. Yang
机器之心编译
参与:panda】
已看一遍,仍有不解
对该文章的大致理解:
张量是一种包含某种标量类型(比如浮点数和整型数等)的 n 维数据结构。可以将张量看作是由一些数据构成的,还有一些元数据描述了张量的大小、所包含的元素的类型(dtype)、张量所在的设备(CPU 内存?CUDA 内存?)
文章着重介绍了步幅(stride)
张量一个数学概念。但要在计算机中表示它,必须定义某种物理表示方法。最常用的表示方法是在内存中相邻地放置张量的每个元素(这也是术语「contiguous(邻接)」的来源),即将每一行写出到内存。为了记住张量的实际维度,必须将规模大小记为额外的元数据。
步幅能将张量的逻辑位置转译为物理内存中的位置:要找到一个张量中任意元素的位置,将每个索引与该维度下各自的步幅相乘,然后将它们全部加到一起。在上图中,我用蓝色表示第一个维度,用红色表示第二个维度,以便你了解该步幅计算中的索引和步幅。进行这个求和后,我得到了 2(零索引的);实际上,数字 3 正是位于这个邻接数组的起点以下 2 个位置。
举个例子,取出一个表示以上张量的第二行的张量:
使用高级的索引支持,只需写出张量 [1, :] 就能得到这一行。重要的是:这样做,不会创建一个新张量;而是会返回一个基于底层数据的不同域段(view)的张量。这意味着,如果编辑该视角下的这些数据,它就会反映在原始的张量中。
在这种情况下,的内部机制:3 和 4 位于邻接的内存中,只需要记录一个说明该(逻辑)张量的数据位于顶部以下 2 个位置的偏移量(offset)。(每个张量都记录一个偏移量,但大多数时候它为零,出现这种情况时会在图表中省略它。
演讲时的提问:如果我取张量的一个域段,我该如何释放底层张量的内存?
答案:你必须制作该域段的一个副本,由此断开其与原始物理内存的连接。你能做的其它事情实际上并不多。另外,如果你很久之前写过 Java,取一个字符串的子字符串也有类似的问题,因为默认不会制作副本,所以子字符串会保留(可能非常大的字符串)。很显然,Java 7u6 将其固定了下来。
再举个例子,取出一个表示以上张量的第一列的张量:
不再将一个元素与下一个元素之间的步幅指定为 1,而是将其设定为 2,即跳两步。(这就是其被称为「步幅(stride)」的原因:如果将索引看作是在布局上行走,步幅就指定了每次迈步时向前多少位置。)步幅表示实际上可以表示所有类型的张量域段.
那么如何实现这一功能呢?
如果可以得到张量的域段,这就意味着必须解耦张量的概念(所知道且喜爱的面向用户的概念)以及存储张量的数据的实际物理数据的概念(称为「存储(storage)」):
也许会有多个张量共享同一存储。存储会定义张量的 dtype 和物理大小,同时每个张量还会记录大小、步幅和偏移量,这定义的是物理内存的逻辑解释。
有一点需要注意:总是会存在一个张量-存储对,即使并不真正需要存储的「简单」情况也是如此(比如,只是用 torch.zeros(2, 2) 划配一个邻接张量时)。
顺便一提,我们感兴趣的不是这种情况,而是有一个分立的存储概念的情况,只是将一个域段定义为有一个基张量支持的张量。这会更加复杂一些,但也有好处:邻接张量可以实现远远更加直接的表示,而没有存储造成的间接麻烦。这样的变化能让 PyTorch 的内部表示方式更接近 Numpy。
在最抽象的层面上,调用 torch.mm 时,会发生两次调度:
第一次调度基于设备类型和张量布局:比如是 CPU 张量还是 CUDA张量,是有步幅的张量还是稀疏的张量。这个调度是动态的:这是一个虚函数(virtual function)调用.
这里需要做一次调度应该是合理的:CPU 矩阵乘法的实现非常不同于 CUDA 的实现。这里是动态调度的原因是这些核(kernel)可能位于不同的库(比如 libcaffe2.so 或 libcaffe2_gpu.so),这样如果想进入一个没有直接依赖的库,必须通过动态调度抵达那里。
第二次调度是在所涉 dtype 上的调度。这个调度只是一个简单的 switch 语句,针对的是核选择支持的任意 dtype。这里需要调度的原因也很合理:CPU 代码(或 CUDA 代码)是基于 float 实现乘法,这不同于用于 int 的代码。这说明需要为每种 dtype 都使用不同的核。
除了密集的 CPU 浮点数张量,还有其它很多类型的张量,比如 XLA 张量、量化张量、MKL-DNN 张量;而对于一个张量库,还有一件需要思考的事情:如何兼顾这些扩展?
当前的用于扩展的模型提供了张量的四个扩展点。首先,有三个独立地确定张量类型的配套参数:
如果想为 PyTorch 张量添加一种扩展,应该思考想要扩展这些参数中的哪几种。这些参数的笛卡尔积定义了可以得到的所有可能的张量。现在,并非所有这些组合都有核(谁为 FPGA 上的稀疏量化张量用核?),但原则上这种组合可能有意义,因此至少应该支持表达它。
要为张量的功能添加「扩展」,还有最后一种方法,即围绕能实现的目标类型的 PyTorch 张量编写一个 wrapper(包装)类(这可能听起来理所当然,但有时候人们在只需要制作一个 wrapper 类时却跑去扩展那三个参数),wrapper 类的一个突出优点是开发结果可以完全不影响原来的类型(out of tree)。
何时应该编写张量 wrapper,而不是扩展 PyTorch 本身?
关键的指标是:是否需要将这个张量传递通过 autograd(自动梯度)反向通过过程。
举个例子,这个指标表示稀疏张量应该是一种真正的张量扩展,而不只是一种包含一个索引和值张量的 Python 对象:当在涉及嵌入的网络上执行优化时,想要嵌入生成稀疏的梯度。
对扩展的理念也会影响张量本身的数据布局。对于张量结构,真正想要的一件事物是固定的布局:不想要基本操作(这个说法很常见),比如「一个张量的大小是多少?」来请求虚调度。
所以查看一个张量的实际布局时(定义为 TensorImpl 结构),会看到所有字段的一个公共前缀——认为所有类似「张量」的东西都会有;还有一些字段仅真正适用于有步幅的张量,但它们也很重要,所以将其保留在主结构中;然后可以在每个张量的基础上完成有自定义字段的后缀。比如稀疏张量可将其索引和值存储在这个后缀中。
PyTorch 的显著特性是其在最初发布时就已提供对张量的自动微分(现在还有 TorchScript 等炫酷功能!)
这是负责运行神经网络的机制:
以及填充实际计算网络的梯度时所缺少的代码:
autograd 的意义就在于执行这幅图所描述的计算,但却不用真正生成这个源。PyTorch autograd 并不执行源到源的变换(尽管 PyTorch JIT 确实知道如何执行符号微分(symbolic differentiation))。
文章中还提到了Variable,但是现在的版本以及将变量和张量融合,不再必需。
如何在 autograd 代码中披荆斩棘、什么代码是真正重要的以及怎样造福他人,我还会介绍 PyTorch 为你写核(kernel)所提供的所有炫酷工具。
PyTorch 有大量文件夹,在 CONTRIBUTING.md 文档中有对它们的非常详细的描述,实际上只需知晓 4 个目录:
实践中如何分离这些代码的:
当调度一个函数,比如torch.add:
其中每一步都具体对应于一些代码:
在 C++ 代码中的起始着陆点是一个 Python 函数的 C 实现,已经在 Python 那边见过它,像是 torch._C.VariableFunctions.add。THPVariable_add 就是这样一个实现。
对于这些代码,有一点很重要:这些代码是自动生成的。如在 GitHub 库中搜索,没法找到它们,因为必须实际 build PyTorch 才能看到它们。
另外一点也很重要:不需要真正深入理解这些代码是在做什么,应该快速浏览它,知道它的功能。
在上面用蓝色标注了最重要的部分:可以看到这里使用了一个 PythonArgParser 类来从 Python args 和 kwargs 取出 C++ 对象;然后调用一个 dispatch_add 函数(红色内联);这会释放全局解释器锁,然后调用在 C++ 张量自身上的一个普通的旧方法。在其回来的路上,将返回的 Tensor 重新包装进 PyObject。
在 Tensor 类上调用 add 方法时,还没有虚调度发生。相反,有一个内联方法,其调用了一个内联方法,其会在「Type」对象上调用一个虚方法。这个方法是真正的虚方法(这就是说 Type 只是一个实现动态调度的「小工具」的原因)。
在这个特定案例中,这个虚调用会调度到在一个名为 TypeDefault 的类上的 add 的实现。这刚好是因为有一个对所有设备类型(CPU 和 CUDA)都一样的 add 的实现;如果刚好有不同的实现,可能最终会得到 CPUFloatType::add 这样的结果。正是这种虚方法的实现能让最终得到实际的核代码。
也希望这张幻灯片很快过时;Roy Li 正在研究使用另一种机制替代 Type 调度,这能让更好地在移动端上支持 PyTorch。
PyTorch 为有望编写核的人提供了大量有用工具。
编写核需要?
一般将 PyTorch 中的核看作由以下部分组成:
要充分利用 PyTorch 的代码生成能力,需要为算子写一个模式(schema)。这个模式能提供函数的 mypy 风格类型,并控制是否为 Tensor 上的方法或函数生成捆绑包。还可以告诉模式针对给定的设备-布局组合,应该调用算子的哪种实现。
有关这种格式的更多信息,请参阅:https://github.com/pytorch/pytorch/blob/master/aten/src/ATen/native/README.md
可能也需要为在 derivatives.yaml 中的操作定义一个导数。
错误检查可以在低层 API 完成,也能通过高层 API 实现。低层 API 只是一个宏 TORCH_CHECK,其接收的是一个布尔值,然后还有任意数量的参数构成错误字符串(error string)以便得出结论看该布尔值是否为真。
这个宏有个很好的地方:可以将字符串与非字符串数据混合起来;每一项都使用它们的 operator<< 实现进行格式化,PyTorch 中大多数重要的数据类型都有 operator<< 实现。
高层 API 能免于反复编写重复的错误消息。其工作方法是:首先将每个张量包装为 TensorArg,这包含有关张量来处的信息(比如其参数名称)。然后它提供了一些预先装好的用于检查多种属性的函数;比如 checkDim() 测试的是张量的维度是否是一个固定数值。如果不是,该函数就基于 TensorArg 元数据提供一个用户友好的错误消息。
在用 PyTorch 写算子时,有一点很重要:往往要注册三个算子:abs_out(其操作的是一个预分配的输出,其实现了 out= keyword 参数)、abs_(其操作的是 inplace)、abs(这只是一个算子的普通的旧功能版本)。
大部分时间,abs_out 是真正的主力,abs 和 abs_ 只是围绕 abs_out 的薄弱 wrapper;但有时候也可为每个案例编写专门的实现。
要执行 dtype 调度,应该使用 AT_DISPATCH_ALL_TYPES 宏。这会获取进行调度操作的张量的 dtype,并还会为可从该宏调度的每个 dtype 指定一个 lambda。通常而言,这个 lambda 只是调用一个模板辅助函数。
这个宏不只是「执行调度」,它也会决定核将支持的 dtype。这样,这个宏实际上就有相当多一些版本,这能选取不同的 dtype 子集以生成特定结果。大多数时候只需要 AT_DISPATCH_ALL_TYPES,但也要关注可能需要调度其它更多类型的情况。
在 CPU 上,通常需要并行化代码。过去,这通常是通过直接在代码中添加 OpenMP pragma 来实现。
某些时候,必须真正访问数据。PyTorch 为此提供了相当多一些选择。
这种代码相当疯狂,请不要添加它。如果你想写代码但对核编写了解不多,能做的一件有用的事情:将某些 TH 函数移植到 ATen。
如果 PyTorch 那庞大的 C++ 代码库是阻拦人们为 PyTorch 做贡献的第一只拦路虎,那么工作流程的效率就是第二只。如果想用 Python 习惯开发 C++,那可能会很艰辛:重新编译 PyTorch 需要大量时间,也需要大量时间才能知道修改是否有效。
原文地址:http://blog.ezyang.com/2019/05/pytorch-internals/