《智能计算系统》第五章 编程框架原理(上)课程笔记 ...

东湖之滨  金牌会员 | 2024-7-3 19:49:23 | 来自手机 | 显示全部楼层 | 阅读模式
打印 上一主题 下一主题

主题 943|帖子 943|积分 2833

《智能计算系统》第五章 编程框架原理(上)课程视频链接:https://www.bilibili.com/video/BV1Ei421i7Rg
本文源自于B站国科大计算所 智能计算系统课程官方账号 所公开上传的视频,在原有视频之上,提取了关键帧、将音频转成了笔墨并进行了校正,以便学习利用。在此,也感谢国科大计算所 智能计算系统课程官方能够将该课程公开!
若侵权,请联系删除。
智能计算系统 第五章 编程框架原理(上)

各人好,下面我们来先容智能计算系统第五章编程框架原理

这个是第五章的提纲,首先我们会先容编程框架的计划原则,然后我们会围绕编程框架的重要的四大模块儿,分别去先容编程框架中的计算图构建、计算图实行、深度学习编译以及分布式训练的重要的原理

然后最后是本章小结

概述

首先先容为什么要了解编程框架原理,了解编程框架的原理对学习智能计算系统来说是有很大的意义的,它有助于步伐员编写与框架底层更为契合、性能更优的代码,从而提拔算法的实现效率
别的,在面临新的算法和硬件挑战的时候,步伐员还能够具备定制化扩展培训框架以支持以提供支持的这个能力
那么在一个编程框架中,它重要包罗了四个模块,分别是计算图构建模块计算图实行模块深度学习编译模块分布式训练模块
其中计算图构建模块计算图实行模块是必要的,而深度学习编译模块分布式训练模块是为了追求更高的性能而需要的
那在本章中,我们首先先容编程框架的计划原则,再先容编程框架内部的团体架构;计划原则会抽象的引导编程框架的计划,而团体架构则是对这一引导的一个详细的细化实现


计划原则

不同的编程框架,它拥有各自的计划哲学
大要上总结可以分为三种,简洁性、易用性、高效性
简洁性是指由框架提供一套抽象的机制,那用户只需要关心宏观上的操作,而不需要去关心微观上的详细实现
像我们这个例子内里,我们要去实现一个把张量 b 和 a 去做加法,得到 c 这样的一个结果,其中张量 b 本来在 CPU 上,我们需要把张量 b 先搬运到 GPU 上,然后再去做张量 a 和 b 的这个加法的操作
那么在利用编程框架进行对这个功能进行编程的时候,我们只需要显示的去指定一个张量存放的位置和张量移动的这个时机;但是详细的这个张量应该怎么样存储,怎么样在设备间移动,是不需要用户去操心的,由可以交由编程框架去维护

我们再来看易用性;Pytorch,它自诞生就坚持 Python 优先,它提供了符合 Python 语言和库管理的一个交互方式,而不是一套需要重新学习的内嵌语言
那 Pytorch 的用户接口计划思路也一直忠于 Python 的语言惯例和开发习惯

高效性是指编程框架应进行充分的优化,从而尽量提高用户应用步伐的运行效率
比如说像 Tensorflow 这个编程框架,他就提供了这个声明式的静态图编程方式,这样使得编程框架可以得到一个完整的计算图并进行全局的优化
那现有的编程框架大部都大部分都支持深度学习的编译技术,通过这个多层级的表现优化来充分的利用用户硬件的一个计算的能力
同时也基本上都支持多机多卡条件的一个分布式训练,从而能够高效支持大规模深度学习的任务

团体架构

编程框架的团体结构重要分成四大模块,分别是计算图构建模块分布式训练模块深度学习编译模块以及计算图实行模块
计算图构建模块,它的功能是完成从输入的用户步伐到编程框架内部原始计算图的转换过程,是编程框架的入口模块
分布式训练模块,它重要是应对更大规模的神经网络,将训练推理任务从一台设备扩展到多台设备
深度学习编译模块,是对计算图分别进行图层级和图算子层级的编译优化,从而提拔单设备上的实行效率
最后是这个计算图实行模块,它是将优化后的计算图中的张量和操作映射到指定的设备上去进行详细实行,并给出编程框架的输出结果

那么我们来看一下这张图,用户通过编程框架去编写用户步伐,编写完了以后的步伐,通过编程框架的计算图构建模块,构建出这个用户步伐对应的这个原始的计算图
那有了这个原始的计算图以后,通过编程框架中提供的这个分布式训练机制对原始计算图在多个计算设备上去进行拆分,得到不同计算设备上拆分后的计算子图
那对于这个拆分以后的计算子图,我们在应用深度学习编译这个模块,这个深度学习编译模块儿它会针对计算图分别进行图层级和算子层级的编译优化
得到优化后的计算图,针对这个优化后的计算图,我们利用计算图实行模块,将优化以后的计算图中的张量和操作映射到指定设备上进行详细实行,并且给出编程框架的输出结果
接下来我们就针对编程框架中的 4 个模块的原理去进行简朴的先容

计算图构建

首先先容计算图构建模块

计算图它重要是由两个基本的元素构成,分别是张量以及张量操作,张量就是我们其中称谓的tensor,张量操作就是operation
计算图它是一个有向图,包罗了有向的边,这些有向边就指明了张量的流动方向
下面这张图它展示了一个神经网络,从源代码到构建正向和反向计算图的过程
正向传播会构建正向计算图,反向传播是利用自动求导的原理来去构建反向计算图
那么我们这一末节会首先从正向传播的计算图构件讲起,在先容反向传播时计算图构件的详细原理

正向传播

在正向传播中, 输入张量经过搭建的神经网络层层计算传递,最终得到计算结果
那么计算图它的构建形式重要分成两种,一种是动态图,一种是静态图
动态图它的意思是说在实行函数的时候,我会按照函数顺序逐条语句的去生成节点,然后立刻计算返回结果。它的优点就是易调试,但是它的性能优化空间有限
静态图是和动态图相对的,它是在实行计算之前已经构建好了所有图上的节点,然后在图运行的时候才计算整个计算图,并且返回最终的结果,那么它的优点和取它的优点就是性能好;那么它的缺点就是不易调试

动态图

在PyTorch 1.X的版本里重要利用的就是这个动态图的机制,动态图就是说计算图在函数运行过程中逐渐构建,那它实行的是一种称为立刻模式(Eager)的这样的一个计算过程,就是每次调用语句就会立刻实行计算,在 Pytorch 中利用的就是动态图的机制,就是每次实行一条语句就会重新构建这个计算图
那我们来看一下左下这张图的这个代码,前四行代码,它分别构建了四个张量,那对应的我们就会构建在计算图中构建相应的四个节点,Wh、 Wx 、x以及这个prev_h 这样的四个张量节点
接下来的两行语句分别是进行了两个矩阵乘法的计算,得到了这个 h2h 和 i2h 这样的两个张量,那么我们对应的也在计算图中添加了这个两个矩阵乘的节点,以及 h2h和 i2h 这样的两个张量的节点
再接下来的两行代码,我们去计算 next_h
然后最后一行代码是计算loss相应的,每实行完一条,语句都会在计算图中构建出对应的计算节点、张量节点以及这个对应的边
当我们的语句编写完成以后,我们的计算图也就构建完成
那么无论这一系列语句它是否在同一个函数内里,我们在下一次构建的时候,在下一次调用的时候都会重新构建,即使这个图的结构可能和这个图是完全雷同的

静态图

那我们再来看静态图,静态图指的是整个网络的结构会在开始计算前就建立完成计算图,然后在框架实行时它会接收整个计算图,而不是单一的语句
在 Tensorflow 1.X中利用的就是静态图的机制,它利用若干基本控制流的算子,实在它一共利用了 5 个基本的控制流算子,分别是 Switch、Merge、Enter、Exit 和 NextIteration 这五个控制流算子,它通过这五个控制流的算子的这个不同的组合能够去实现一些,比如说像跳转、像循环等等这样一些比较复杂的控制流的场景
在 Pytorch 2.X的版本内里,同样也支持静态图的机制。在 Pytorch 2.0 中采取了图捕获TorchDynamo 的这个技术,将用户的动态图转化为静态图

反向传播

那我们再来看反向传播,在正向传播的时候,正向计算得到的这个结果和目标结果之间通常会存在一个损失函数的值
然后我们就会利用损失函数对我们的参数去进行求导,然后得到梯度,并且利用这个梯度去更新我们的参数
在这个反向传播的过程中, PyTorch 就会根据正向传播的计算图自动生成对应的反向计算节点,并且把它加入到计算图中去,构建成反向传播计算图

反向传播过程中一个必不可少的步调就是要计算导数,那么自动微分就是一种计算导数的方法,现在常用的几种求导的方法包括了手动求导法数值求导法符号求导法自动求导法

  • 手动求导法就是指利用链式法则求解出梯度公式,然后根据公式去编写代码,代入数值计算得到梯度结果
  • 数值求导法的含义是直接代入数值来近似求解
  • 符号求导法是直接对代数表达式进行求解,最后代入问题数字,它会有表达式膨胀的问题
  • 自动求导法就是用户只需要描述前向计算的过程,然后由编程框架自动的推导反向计算图,它会先建立表达式,再带入数值计算,那自动求导法是当前的这个编程框架都会支持的求导的方法

手动求导法

好,我们先来看手动求导法,它就是指手动用链式法则求解除梯度公式,然后代入数值得到最终的梯度值,这是一种非常直观的这个求导的方法,那么它的缺点就是对于大规模的深度学习算法,手动用链式法则进行梯度计算,并且转换成计算步伐是非常困难的
我们需要手动去编写梯度的求解代码,当模子发生变化的时候,算法也需要随之修改

数值求导法

那数值求导法就是指利用导数的原始定义去进行求解,就是我们下面给出来的一个公式,我们利用一个非常小的一个数h,然后把它带入到这个公式中,就能求得某一点处的这个函数的这个导数,那么数值求导法它的优点就是易操作,并且可以对用户隐藏求解的过程,那它的缺点就是计算量大,并且它的求解速度比较慢,同时它可能会引起舍入误差和截断误差

符号求导法

符号求导法是利用求导规则来对表达式进行自动操作,从而得到导数
我们会把常见的一些求导规则,比如说加法求导规则、减法求导规则、乘法除法这些求导的规则带入到表达式上,得到这个求导以后的表达式,然后最后再带入我们的这个数值,那么它的问题就是很容易会带来表达式膨胀的这个问题,最终导致求解的速度变慢
我们以下面这张图为例来去先容表达式膨胀的含义啊
那么我们有一个\(l_n\),这个\(l_n\)是和\(x\)有关的,随着\(n\)的变化,它\(l_n\)的表达式会变得更复杂
那么我们对\(l_n\)去计算\(l_n\)相对于\(x\)的导数,这个第3列表现的是利用符号求导法求得的这个表达式的结果,这个第4列表现的是这个利用手动求导法去计算这个导数的这个结果
那我们会发现当\(n\)等于 4 的时候,利用符号求导法计算出来的这个表达式相比于手动求导法求得的这个结果来说已经是非常的复杂了。以是随着这个\(n\)逐渐增大,这个求导的结果就会出现表达式膨胀的问题

自动求导法

现在编程框架中利用的基本上都是自动求导法,那么根据我们前面的先容,数值求导法,它强调一开始直接带入数值去做近似的求解;符号求导法,它强调直接对代数表达式去进行求解,然后才代入问题数值,那么它们都有各自的一些问题。
自动求导法,它是介于数值求导和符号求导方法之间的一种方法,那它首先将符号求导法应用于最基本的算子,比如说幂函数、指数函数、对数函数、三角函数等等,然后再代入数值,保留中间的计算结果,最后再应用于整个函数

整个的计算,它分成两步实行:首先第一步根据原始函数建立计算图,数据正向传播,然后我们会计算出所有的中间节点,并且记录计算图中的节点依赖关系
第二步我们会反向遍历计算图,计算输出对每一个节点的导数
那对于前项计算中一个数据连接多个输出数据的情况,在自动求导法里会把这些输出数据相对于该数据的导数去进行一个累加

我们这里根据一个例子来去先容这个自动求导法,它的这个计算过程,我们有一个想要去计算\(f(x_1, x_2)=(e^{x_1}+x_2)(x_2+1)\)这样的一个函数,那么首先第一步我们会根据这个函数的内容去创建计算图
创建完计算图以后,我们会让输入数据\(x_1\)和\(x_2\)去进行一个正向的传播,计算出所有的中间节点,\(x_3\),  \(x_5\),  \(x_4\), \(x_6\) 以及\(y\)(下图右半部分),并且记录计算图中的节点依赖关系
那么我们在做前向计算的时候,会把每一步的中间计算、中间结果都存下来,由于这个在反向计算的时候是要用到的

那第二步我们就要去进行反向计算,要计算输出对每一个节点的导数,那我们就看这个左边下边的这张图,我们从输出开始,向输入的方向逐个节点去计算它们的这个导数。
备注:反向传播的图是怎么来的呢?在下面会有PyTorch的代码,个人理解是:对于正向传播中的每一条,都会有相应的反向传播的节点;而不是正向传播中的每一个节点对应反向传播的节点,这个图虽然画的很清楚,但是感觉还是会产生一定误导的=_=
右边这张图我们从下往上看,首先计算第一个节点,也就是 \(y\)相对于\(x_6\) 这个节点的导数,由于 \(y\) 它等于\(x_6\),以是这个 \(y\) 相对\(x_6\)这个节点的导数就为1
然后我们再看\(x_5\),再看这个 \(y\) 对 \(x_5\) 这个节点的导数
根据链式法则, \(y\) 对 \(x_5\) 这个节点的导数,它等于 \(y\) 对 \(x_6\) 这个节点的导数,乘以 \(x_6\) 对\(x_5\)这个节点的导数(\(\frac{\partial y}{\partial x_5}=\overline{x_5}=\frac{\partial y}{\partial x_6}\cdot\frac{\partial x_6}{\partial x_5}\)),那么 \(y\) 对 \(x_6\)这个节点导数在上一步中已经计算求得了(\(\frac{\partial y}{\partial x_6}=\overline{x_6}\)),那我们只要计算出 \(x_6\)对 \(x_5\)这个节点的导数,它等于\(x_4\)(\(\frac{\partial x_6}{\partial x_5}=x_4\));那我们就把之前向传播时保存的\(x_4\)节点的计算值带入到这个公式中,就求得了这个 \(y\) 对 \(x_5\) 这个节点的导数
小结:我们通过链式法则逐步的就计算出输出对每一个节点的导数。

那这里需要特殊关注的,就是\(x_2\)这个节点;它的特殊之处就是:它有两个分支,分别去连向\(x_5\)这个节点以及\(x_4\)这个节点。那么我们在计算导数的时候,我们就会分开分别计算:先去计算\(x_5\)这个分支,从这个分支上 \(y\)结点对于\(x_2\)的导数,得到这个\(\overline{x_2}^1\),然后再去计算\(x_4\)这条分支上, \(y\)节点对这个\(x_2\)的导数,这个\(\overline{x_2}^2\);然后我们再去把求得的这两个分支上的导数进行相加(\(\overline{x_2}^1+\overline{x_2}^2\)),就得到了最终\(y\)对\(x_2\)这个结点的导数。
那这样的一种自动求导的方法,它实际上是兼具了数据求导法和符号求导法的优点,它很容易操作,并且不会有表达式膨胀的问题。
像神经网络这种模子,它通常输入是上万到上百万维,那输出的损失函数只有一维,那这样的模子我们只需要一遍自动求导的过程,就可以求出输出对各个输入的导数

各种求导方式对比

我们对四种求导方法去进行了一个比较

  • 从精度上来看,手动求解法、符号求导法和自动求导法都有比较高的精度
  • 从对图的遍历次数上来看,自动求导法它只需要对图遍历\(N_O+1\),这里的\(N_O\)指的就是神经网络层的这个输出个数。(备注:+1加的应该是正向传播;有几个输出,就要反向传播几次,以是是\(N_O\))
以是利用自动求导法,它对于输入维度较大的情况下,它的性能优势是非常明显的

PyTorch中的自动求导

在 Python 之中利用的是AutoGrad这个自动微分引擎,用户只需要一行代码,也就是tensor.backward()就可以调用它来去自动的计算梯度,并且进行反向传播
在 PyTorch 中的这个 AutoGrad模块内里,这个 backward 函数它的实现步调重要有三步:第一步是正向图的解析(获取反向图节点和梯度节点),第二步是构建反向计算图的节点(获取反向图的),第三步是进行反向梯度的传播

那我们先来看第一步正向图的解析;这个内里列出来了PyTorch框架中的源代码部分,对于正向图解析这一部分,这个代码在这部分内里我们会根据输入配置了roots和 grads这两个变量,其中roots是反向传播的节点的集合,同时也是前向传播的输出节点的集合,grads它是反向传播需要的梯度节点集合

第二步是构建反向计算图的节点;我们通过遍历正向图中的节点,构建反向计算图中的边output_edges:对inputs中的每个元素都获取它对应的张量,检查这个张量是否是(正向图中的)叶节点,也就是说它是否有梯度函数grad_fn;它如果不是叶子结点的话,就表明这个结点存在梯度函数grad_fn,那么我们就创建一条从梯度函数到该节点的边;这样遍历完所有的输入,我们就构建出了反向计算图中所有边的集合output_edges

第三步就是进行反向梯度传播,此时反向计算图已经构建完成了,那么我们就调用这个engine.execute(...)来去进行反向的梯度传播

计算图实行模块

下面我们来先容编程框架中的计算图实行模块

那在这一节内里,我们会报告如何将给定计算图中的张量以及操作映射到给定设备上详细实行的整个过程
我们会首先先容在编程框架中设备管理的方法,然后先容张量的实现方法,最后讲解计算图中的算子是如何完成实行的

设备管理

首先来看设备管理,设备是编程框架中计算图实行时的硬件实体,每个设备都详细负责计算子图中的张量存放和算子计算
那么常见的设备包括通用的处理器,也就是CPU,以及我们领域专用的处理器,比如GPU,大概是深度学习处理器DLP
那通用处理器 CPU 的管理方法是比较简朴的,这里我们就不进行先容了
在编程框架的开发内里,重要需要添加的是对领域专用的处理器,也就是 DLP 的这个设备管理的支持,它重要包罗包括了三个模块,分别是设备操作实行流管理以及设备管理的模块

PyTorch中的设备被直接按照类型去进行分类,比如说CPU,CUDA, DLP 等等
一个设备是由一个类型和一个设备索引大概是序列来去唯一标识的它;前者指定了机器的类型,比如说是 CPU 大概是 GPU 等等,那后者是在有多个特定类型的计算设备时去标识特定的计算设备
下图中parse_type的实现在:pytorch/c10/core/Device.cpp at v2.4.0-rc7 · pytorch/pytorch (github.com)

为了支持设备管理,Pytorch它在DeviceGuardImplInterface.h内里定义了抽象的设备管理类,叫做DeviceGuardImplInterface。这个抽象的管理类,它提供了对设备管理统一的一个抽象接口,那在这个DeviceGuardImplInterface类内里提供了对设备操作实行流管理变乱管理的函数接口计划
实行流是设备上抽象出来的管理计算任务的软件概念,用于在领域专用处理器上的异构编程模子下,完成设备上任务实行的下发和同步的操作。那详细来说,下发到同一个实行流中的任务具有串行性,下发到不同实行流上的任务能并发实行;因此在编程时候,用户是可以创建多个实行流,并将计算任务分配到不同的实行流中的,从而到达任务并发实行的结果;典型的实行流管理操作包括了实行流创建实行流同步实行流销毁
变乱也是设备在软件层面抽象出来的概念,它重要是用来表现设备上任务运行的状态和进展,比如记录变乱之间的时间隔断,从而计算设备运行时间等;变乱管理它重要包括计算创建变乱创建、时间记录和变乱销毁等基本操作

那我们来看一下在这个DeviceGuardImplInterface这个类内里,我们就可以看到它提供了对设备操作、实行流管理和变乱管理的函数接口计划
https://github.com/pytorch/pytorch/blob/v2.4.0-rc7/c10/core/impl/DeviceGuardImplInterface.h#L57

张量实现

逻辑视图与物理视图

再来看张量实现,张量是神经网络算法内里利用的基本的数据结构,它也是计算图内里的核心概念之一,对应了计算图中不同张量操作之间传递流动的数据
张量它有一些基本的属性,像形状、布局、步长、偏移量、数据类型和设备等等,这些基本的属性可以统称为张量数据结构的逻辑视图;张量数据结构的逻辑视图是编程框架利用者软件层面上直接控制和表达的一些基本属性
那对编程框架开发者来说,我还需要去维护张量数据结构的另外一种视图,就是物理视图。张量数据结构的物理视图,它重要包括在设备上的物理地点空间巨细指针数据类型等属性;物理视图是编程框架底层需要维护的基本属性,它对编程框架利用者来说是不可见
那我们下面这张表就是张量数据结构的逻辑视图和物理视图的一个基本属性的对比

张量数据结构(逻辑视图、物理视图示例)

逻辑视图里重要是通过两个关键变量,也就是偏移量步长,来去最终确定它所对应的张量在物理视图物理地点空间的寻址方法
我们先来看下左图,当我们利用A[:, 0]来去索引张量A的第一列数据的时候,这个切片以后的张量的逻辑视图就酿成了一个形状为[2]的张量[1, 3],然后它的步长为2,它的数据类型是int32
备注:上面的这个形状是我拿PyTorch试出来的,下面的行切片也是,和课程中老师说的不太一样。但神奇的是,如果在选切片时,利用的是A[: , 0:1],出来的形状就是[2,1]了...
  1. >>> a = torch.randn(2,2)
  2. >>> a[:, 0].shape, a[1, :].shape
  3. (torch.Size([2]), torch.Size([2]))
  4. >>> a[:, 0:1].shape, a[1:2, :].shape
  5. (torch.Size([2, 1]), torch.Size([1, 2]))
复制代码
那这样的一个切片操作,它实在并没有隐式的去创建一个新的张量并且拷贝,而只是提供了本来物理视图下的一个新的逻辑视图;那它的物理视图仍旧是这个物理地点空间从0x10这个位置开始连续存储的一块数据,但是由于它的步长等于2,以是在进行物理地点空间寻址的时候,每访问一个元素,我都要跳跃两个元素,从而对应了是0x10和0x18两个位置的数据
同理,这个下图右边的这个图,当我们利用张量的索引方法 A[1, :]来去索引张量的第二行数据的时候,这个张量的逻辑视图就酿成了一个形状为[2]的这个张量;它的步长为1,数据类型仍旧是int32;这个时候我们就需要在逻辑视图中额外引入偏移量的属性,来记录这个新的逻辑视图对应的张量在物理视图上数据实际开始的位置

PyTorch中的张量抽象

我们再来看一下编程框架中对张量的这个支持。右边这张图显示了 PyTorch 编程框架中实现张量的数据调用关系
那么张量在PyTorch 之中,它存在一个和张量对应的类Tensor,这个 Tensor 它是继承了它的基类 TensorBase而来;TensorBase类再进一步去调用相关的结构体,最终支持张量在不同的设备类型,像CPU, GPU 和 DLP 上得到支持

在 Python 中是通过张量的抽象类Tensor以及这个存储抽象类Storage来去分别表现张量数据结构中的逻辑视图和物理视图
其中TensorImpl类,它是张量抽象的实现,包罗了维度信息、步长信息、数据类型、设备布局等逻辑视角的张量信息
StorageImpl类,它是张量的一个存储实现,包罗了内存、指针、数据总数等物理视角的张量信息,它会调用结构体allocator去进行张量数据空间的一个分配
那么我们来看一下底下这张图,就显示了在PyTorch编程框架中张量实现的流程
首先Tensor类是由TensorBase类继承而来的,在TensorBase类中包罗了唯一的成员变量impl_,这个相称于一个指向TensorImpl的指针,并且它表达了前述张量数据结构中的逻辑视图
TensorImpl又利用结构体Storage来去表现和张量存储相关的信息
一个Storage代表一个张量的底层,支持数据缓冲区(原视频也不太清楚(?TODO)),并且唯一的拥有一个指针storage_impl_, Storage(还是Impl?(?TODO))会调用结构体allocator去进行张量数据空间的分配

根据后端设备的不同, allocator的代码实现也会有不同

张量内存分配

张量的数据结构,包罗了逻辑视图和物理视图两种表现;从逻辑视图到物理视图的转换需要完成对张量的内存,也就是对张量进行内存管理
那根据设备的类型不同,张量管理的方式也有不同,对于 CPU 来说,重要采用即时分配的这个方式;对GPU来说,重要采用的是内存池分配的方式

即时分配

我们先来看 CPU 中采用的即时分配方式,当需要分配张量内存的时候,就立刻从系统中申请一块合适巨细的内存,这个就是即时分配;核心代码部分就是malloc分配空间以及free开释空间

内存池分配

内存池分配,它是一种预先分配一块固定巨细内存池,然后在需要时从内存池中分配内存的策略;分配的内存它来自事先分配好的内存块,而不是每次都向系统申请新的内存
内存池分配不仅仅是提供了简朴的内存的预分配,它还具备一个自我维护的能力,能够灵活地处理内存块的拆分和合并等等操作
那在领域专用处理器,比如 GPU 大概是 DLP 上,需要利用处理器的运行时接口来去管理设备端内存,通常就会通过内存池的方式去管理设备端的张量
这种做法相比于调用系统 API 直接分配内存来讲,它不仅减少了系统调用开销,同时还具备两个重要的优势,一个是节省了设备内存的利用,并且它减少了设备内存的碎片化问题

分配好内存以后,就可以建立张量。建立张量时需要对张量进行初始化,那这个初始化的步调是首先根据固定内存标志去选择相应的分配器allocator;这个 allocator可以是即时分配,也可以是内存池分配。
在我们下面这个例子里,利用的是 CPU 内存分配,也就是即时分配
然后我们会调用这个_empty_generic去进行详细的实现;这个_empty_generic它实现了通用的张量创建逻辑,也就是说我首先去分配存储空间,然后去创建StorageImpl类,去创建TensorImpl类
其他的硬件后端,比如说 GPU 大概 DLP 上去进行张量初始化的时候,我们只需要去选择相应的分配器,并且设置对应的参数,然后再调用_empty_generic就可以实现

算子实行

我们再来看算子的实行计算图的过程,它可以被分解为每个算子单独实行的过程

  • 首先通过计算图生成一个实行序列,这个序列会确定算子的实行顺序来确保精确的数据流和依赖关系
  • 然后我们针对每个算子去进行算子实现,包括前端定义后端实现前后端绑定三个步调
  • 最后进行分派实行,包括查找适合给定输入的算子实现调用相应的实现来实行详细的计算任务

实行序列

我们先来看实行序列的确定。计算图它描述了算子之间的依赖关系,通过分析计算图节点之间的依赖关系就可以得到算子的实行序列
这个序列一般可以利用拓扑排序的算法来得到,它的详细的流程是:首先我要计算每一个节点的入度然后我利用拓扑排序算法对计算图的节点进行一个排序,这个通常会采用深度优先搜刮大概是广度优先搜刮来去遍历图中的节点;最后我们要检查结果,检查这个实行序列的长度是否是等于图中的这个节点数
像我们这个例子中,我有一个计算图的节点,这个内里包罗了 5 个算子,那么我们就利用拓扑排序方法,给出了这 5 个算子的一个实行序列
那么需要注意的是,对同一个计算图,我们可能会得到多个不同的结果;这是由于在求拓扑序列的过程中,可能有多个入度为0的节点,我们可以从中任意选择下一个实行的节点

算子实现

我们再来看算子实现,那在得到算子的实行序列以后,每个算子都需要在编程框架中完成对应的算子实现
在我们的编程框架内里,用户的接口(也就是前端),和详细的实现(也就是后端)一般会采用不同的编程语言,比如说在PyTorch内里,它的用户前端一般是利用Python作为这个前端的编程语言,然后用C++来去作为它的后端的编程语言
算子实现的流程首先是做前端定义,就是指在编程框架中配置算子信息,这里边包罗了算子的输入输出以及相关的接口定义,最后再生成前端的接口
第二步后端实现,就是利用C++大概是其他高级的编程语言来去编写算子的底层实当代码,完成算子的计算逻辑部分的实现
最后前后端的一个绑定,在这一步调内里,我们编程框架把前端定义的算子和后端的详细实现去进行一个绑定

前端定义

PyTorch提供了一种高效的模式用于管理整个算子实现模块,我们称为native_function。那在利用native_function这个模式进行算子实现的时候,需要修改配置文件native_functions.yaml以添加算子配置信息
这个native_functions.yaml这个函数中包罗了几个字段:
第一个字段是func,func字段定义了算子的名称输入输出的参数类型
第二个字段是variants,这个字段表现需要自动生成的高级方法
第三个字段是dispatch,这个字段表现该算子所支持的后端类型和对应的实现函数
那下面这段代码示例就显示了这个native_function函数的格式,我们可以看到内里包罗了这个 func字段、 variants字段以及dispatch字段

我们以 PyTorch 中的PReLU这个算子实现为例,说明利用 native function 模式在 PyTorch 中实现一个以 CPU 为后端运行的算子的流程
那我们这张代码,它展示了 PyTorch 中PReLU算子的配置文件,也就是 native 函数,包罗了PReLU算子实现部分:PReLU正向传播函数实现和PReLU反向传播函数实现
这个操作,它接受两个张量作为输入,其中self表现输入张量, weight表现该操作的可学习的参数
dispatch字段,它表现PReLU支持的后端类型和对应的实现函数

PReLU算子的前端实当代码如图所示,这段代码实现了PyTorch中PReLU类的定义,包括PReLU类的相关描述以及正向计算逻辑和可学习参数
然后需要在配置文件中添加算子正向传播函数反向传播函数对应关系
我们这段示例代码就表明了正向传播函数_prelu_kernel,它对应的反向传播函数是_prelu_kernel_backward

后端实现

后端实现步调包罗了算子的表层实现底层实现两部分
表层实现

表层实现可以看作不同设备之间的抽象函数接口底层实现可以看作详细到某个设备上的实际代码实现,表层实现和底层实现的代码中均需要包罗正向传播函数和反向传播函数,二者的结构是类似的
首先我们创建一个空的返回对象,然后通过配置TensorIteratorConfig来去创建一个TensorIterator用于迭代张量操作
这个Iterator,它提供了统一的计算抽象,封装了正向计算的输入权重以及反向计算的梯度,接下来调用prelu_stub函数进行实现,完成PReLU算子的运算,最后返回计算结果
这里需要注意的是,这个代码中定义的实现只是一个封装,它没有完成真正的实现,还需要根据后端的硬件来去编写对应的这个底层实现

底层实现

底层实现指的就是详细到某个设备上的实际代码实现,比如说在 CPU 上的实现,大概是 GPU 上的实现,大概是 DLP 上的实现
那底层实现中的这个PReLU kernel 函数和表层实现中的 PReLU stub函数会在前后端绑定中去完成对应,这种将表层实现和底层实现解耦的计划使得为多种后端注册内核函数时可以复用相关的接口

前后端绑定

我们再来看前后端绑定,在前后端绑定的步调中,我们为每个后端编写了相应的底层实现步调,以实现各种不同的硬件和软件平台,并且为每一种输入情况都提供了对应的后端实现的重载版本
在前后端绑定中, dispatch它在分派机制中扮演着调理和控制的脚色,确保在不同的后端环境中能选择精确的实现方法
这个dispatch它会维护一个分派表,分派表的表项记录着算子到详细的后端实现的对应关系
分派表可以视为一个二维网格纵轴表现PyTorch支持的算子横轴表现支持的分派键;这个分派键是和后端相应的标识符;这个表初始是为空的,那当添加一个算子到后端实现的对应关系的时候,就需要编写TORCH_LIBRARY_IMPL这个函数去进行注册

分派实行

在得到算子的实行序列并且实现了对应的算子以后,就需要对算子进行分派实行;它指的是在运行时根据输入张量的类型设备类型查找并调用合适的算子实现方式
在分派实行过程中, Dispatcher会首先根据输入张量和其他的信息计算出对应的分派键,然后由该分派键找到相应的内核函数
这里涉及到几个概念,算子,指的是 Dispatcher 的调理对象,它代表了详细的计算任务;分派键,它是根据输入张量和其他信息计算,它可以简朴的理解为与硬件平台相关联的标识符;内核函数,指的就是特定硬件平台上实现算子功能的详细代码


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

东湖之滨

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表