MLSYS编程框架

1. 编程框架概述

1.1 为什么需要编程框架?

深度学习编程框架的出现是为了解决深度学习算法开发和应用中的一系列挑战,主要包括:

  • 算法广泛关注与应用需求: 深度学习算法获得了广泛关注,越来越多的公司和程序员需要使用这些算法。
  • 算法理论复杂性与代码实现工作量大:
    • 深度学习算法本身理论复杂,手动实现算法涉及大量的代码编写工作,效率低下。
    • 为了提高深度学习算法的开发效率,有必要将算法中的常用操作封装成组件提供给程序员使用。
  • 多层结构与共性运算:
    • 深度学习算法通常具有多层结构,每层运算由一些基本操作构成。
    • 这些基本操作中存在大量共性运算,例如:卷积 (Convolution)池化 (Pooling)激活函数 (Activation)自注意力机制 (Self-attention) 等。
    • 将这些共性运算操作封装起来,可以显著提高编程实现效率,避免重复造轮子。
  • 自动微分 (Automatic Differentiation):
    • 深度学习模型的训练严重依赖于梯度计算来优化模型参数。手动计算复杂模型的梯度极其困难且易错。
    • 编程框架能够对用户任意搭建的模型快速高效地自动计算其代价函数相对于所有模型参数的梯度。这极大地方便了用户进行模型训练和优化。
  • 硬件优化利用:
    • 面向这些封装好的操作,硬件程序员可以根据特定硬件(如GPU、NPU)的特征,进行有针对性的充分优化
    • 这种优化能够使深度学习模型在目标硬件上充分发挥计算效率,加速训练和推理过程。

1.2 机器学习框架包含内容

原始资料中,此部分仅为概念性示意,未提供具体内容细节,仅指出机器学习框架包含“全部”或“部分”能力。这暗示了不同框架可能在功能完整性或覆盖范围上有所差异。

1.3 深度学习编程框架定义

  • 定义: 随着深度学习研究的深入,算法日益复杂,研究人员需要投入更多时间在算法实现上。深度学习编程框架应运而生,它将深度学习算法中的基本操作封装成一系列组件。这些组件的集合就构成了一套深度学习框架。
  • 核心作用:
    • 简化算法实现: 帮助算法开发人员更简单地实现已有的算法,或设计全新的算法,将精力集中在算法创新而非底层实现细节。
    • 促进硬件优化: 有助于硬件程序员更有针对性地对关键操作进行优化,使其能充分发挥硬件的计算效率,例如针对GPU或专用AI芯片进行性能调优。

2. 国内外主流编程框架

2.1 主流框架列表

下表列举了当前国内外主流的深度学习编程框架及其发布者和首次发布时间:

序号框架名称发布者首次发布时间
1PyTorchFacebook (Meta)2017
2TensorFlowGoogle2015
3KerasGoogle2015
4CaffeBVLC2013
5PaddlePaddle百度 (Baidu)2018
6OneFlow一流科技 (OneFlow Inc.)2020
7MindSpore华为 (Huawei)2019

2.2 PyTorch概述

2.2.1 PyTorch简介

  • 名称由来: PyTorch = Py (Python) + Torch (一个用于科学计算的开源机器学习库)。
  • 发布者与时间: 2017年由 Facebook AI Research 开源。
  • 核心功能: PyTorch是一个基于Torch的Python开源机器学习库,用于自然语言处理等多种应用程序。它最显著的特点是具有强大的GPU加速的张量计算能力,类似于NumPy库,但支持在GPU上进行高效运算。
  • 产生背景:
    • 高效编程库的出现: 编程语言领域出现了面向深度学习的高效编程库,如NumPy(用于数值计算)、Eigen(C++模板库,用于线性代数)、以及原生的Torch。
    • Python开源生态蓬勃发展: Python语言及其丰富的开源库生态为PyTorch的普及和发展提供了肥沃的土壤。其易用性和强大的社区支持是其成功的关键因素之一。
  • 案例示例 (Driving example-VGG19):
    • 在原始资料的第9页,展示了一个关于VGG19神经网络的PyTorch实现及其在计算设备上运行的示例。
    • 图片内容描述: 图片左侧是一个简化的神经网络模型,标示为VGG19。图片右侧展示了该模型如何在PyTorch中实现,并最终转换为底层硬件(如昇腾AI处理器)上的特定算子代码 (__dlp_entry__ void Proposal(...) { ... __nram__ half scores[...]; __nramset_half(scores, ...); ... __bang_maxpool(...); ... })。这表明编程框架在高级语言中定义算子,然后集成到框架中,最终能够在计算设备上高效执行。
    • 图片作用: 此图旨在说明深度学习框架如何将高级的神经网络概念(如VGG19)通过编程语言实现(如PyTorch),最终编译成底层硬件可执行的优化代码,从而连接了算法层和硬件执行层。
    • 【若需查看原始图片详情,请参考原文中的“Driving example-VGG19”】

2.2.2 设计原则

PyTorch在设计时秉持以下核心原则,使其成为研究人员和开发者首选的工具之一:

  • 基于Python: 充分利用Python语言的简洁性、易用性和丰富的生态系统,降低学习和开发门槛。
  • 把研究人员放在首位: 优先考虑研究人员的需求,提供灵活、动态的计算图机制和方便的调试功能,以支持快速实验和原型开发。
  • 易用性: 提供直观的API和文档,使得用户能够快速上手并高效构建模型。
  • 高性能: 尽管强调易用性,PyTorch也通过GPU加速等技术确保其在计算效率上达到工业级标准。
  • 内部实现简单,节省学习时间: 力求框架内部机制的清晰和简洁,减少用户的理解负担,从而节省学习和排查问题的时间。
  • 在顶会中的高占比: PyTorch在包括EMNLP、ACL、ICLR等在内的顶级学术会议中,其使用率已超过80%,在其他会议中也保持在70%以上,这充分体现了其在学术界和研究领域的主导地位和影响力。

2.2.3 发展历程

PyTorch自2017年发布以来,持续迭代更新,不断增强功能和优化性能。其主要版本更新内容如下:

  • 2017.10.1: 发布了第一个版本,标志着PyTorch的正式问世。
  • 2017.80.2: 增加了对高阶导数分布式计算张量广播等重要功能的支持,扩展了框架的能力。
  • 2018.40.4: 增加了对Windows操作系统的支持,扩大了用户群体;将张量(Tensor)和变量(Variable)合并为张量,简化了API,统一了数据和计算图节点。
  • 2018.111.0: 完善了对不同后端的分布式计算支持;加强了对C++前端的支持,使得生产部署更加灵活;发布了PyTorch Hub,为用户提供一系列预训练模型,方便模型复用。
  • 2019.51.1: 增加了对TensorBoard可视化工具的支持,方便用户对张量、模型结构、训练过程进行可视化分析。
  • 2019.101.3: 增加了对移动端处理的支持,拓展了模型的部署场景;增加了对模型量化功能的支持,有助于模型压缩和加速。
  • 2022.111.13: 发布了BetterTransformer的稳定版,优化了Transformer模型的性能。
  • 2023.32.0: 目前的最新版,增加了编译模式(如torch.compile()),旨在结合动态图的灵活性和静态图的优化潜力。

2.2.4 学习资料

以下是一些推荐的PyTorch学习资源:

  • 有趣的应用集锦: https://github.com/ritchieng/the-incredible-pytorch
  • 斯坦福课程关于PyTorch的介绍: https://cs230.stanford.edu/blog/pytorch/
  • PyTorch官方GitHub仓库: https://github.com/pytorch

3. 自动微分

自动微分(Automatic Differentiation, AD)是现代深度学习框架中用于高效、精确计算模型参数梯度的核心技术。它结合了数值微分的精确性和符号微分的效率,是机器学习模型训练,特别是反向传播算法的基础。

3.1 微分方式概述

在介绍自动微分之前,我们需要了解几种不同的微分方式及其特点。

3.1.1 数值微分
  • 定义: 数值微分通过对函数在某点附近进行微小扰动,利用函数值在两点之间的变化率来近似计算导数。最常见的方法是中心差分法,即 f(x)f(x+ϵ)f(xϵ)2ϵf'(x) \approx \frac{f(x + \epsilon) - f(x - \epsilon)}{2\epsilon}。这种方法在数值上更精确,尤其在泰勒展开的证明中可以体现。
  • 特点:
    • 优点: 定义直接,理解简单。常用于单元测试中验证自动微分算法实现的正确性。
    • 缺点:
      • 数值误差: 由于浮点数精度限制和 ϵ\epsilon 的选择问题,容易引入数值误差。
      • 效率低下: 对于有 MM 个输入变量的函数,需要对每个输入变量进行两次函数求值来近似其偏导数,因此计算复杂度较高。
      • 不适用于复杂计算图: 在大型计算图或具有长链式计算的图中,计算复杂度会急剧增加。
3.1.2 符号微分
  • 定义: 符号微分(Symbolic Differentiation)是指根据微积分的求导法则(如和、积、链式法则等)直接推导出函数的解析导数表达式。
  • 特点:
    • 优点: 能够得到完全精确的导数表达式,没有数值误差。
    • 缺点:
      • 计算复杂性: 导数表达式可能非常复杂,尤其是对于大型计算图,推导和简化过程可能导致表达式爆炸,增加计算量。
      • 不利于优化: 衍生的导数表达式可能包含大量冗余计算,不易进行优化。
3.1.3 自动微分(AD)
  • 定义: 自动微分(Automatic Differentiation, AD)是一种介于数值微分和符号微分之间的方法。它通过将复杂的数学运算分解为一系列基本操作(如加、减、乘、除、指数、对数等),然后对这些基本操作应用链式法则来计算导数。自动微分利用计算图在给定计算图上自动地计算任意偏导数。
  • 核心原理:
    • 计算图: 使用有向图来表示函数计算过程,每个节点代表一个基本操作或变量。
    • 单位算子梯度: 预定义每个基本操作的导数。
    • 链式法则: 沿着计算图应用链式法则,将基本操作的导数组合起来,得到最终的导数。
  • 目的: 主要用于帮助机器学习模型的训练,高效计算损失函数相对于模型参数的梯度。

3.2 计算图

计算图是自动微分和深度学习框架的基础,它以图形化方式表示数学表达式的计算过程。

3.2.1 节点和边
  • 计算图构成: 编程框架中使用有向图来描述计算过程,包含一组节点和边。
  • 节点(Nodes):
    • 表示对象: 节点一般用来表示各类操作(如数学运算、变量读写、数据填充等),也可以表示输入数据、模型参数或输出数据。
    • 叶子节点(Leaf Nodes): 通常指那些不是由其他操作结果生成的变量或参数,例如模型的输入数据和可训练参数。
    • 中间节点(Intermediate Nodes): 通常代表各类算子(操作)的输出结果。
    • 示例: 在表达式 y = w * x 中,wx 是叶子节点,*(乘法操作)是一个中间节点。
  • 边(Edges):
    • 表示关系: 边表示“节点”之间的输入输出关系。
    • 数据流边: 传递具体数据的边。这些数据通常以**张量(tensor)**的形式在图中流动。例如,x 的值通过边传递给乘法操作。
    • 控制依赖边: 表示节点之间执行顺序的边。这类边不传递数据,只表示某个节点必须在前序节点计算完成后才能开始执行。
  • 示例图(y = w * x):
    • 节点:w (变量), x (变量), matmul (操作), y (输出)
    • 边:w -> matmul, x -> matmul, matmul -> y 【若需查看原始图片详情,请参考原文中的“计算图示例:y=w*x”】
  • 复杂示例(Forward evaluation trace): 考虑函数 y=f(x1,x2)=ln(x22+x2x1)sin(x1)y = f(x_1, x_2) = \ln(x_2^2 + x_2 x_1) - \sin(x_1)。 这个表达式可以分解为一系列基本操作,并构成一个计算图,其中 x1,x2x_1, x_2 是输入,中间会生成 v3,v4,v5,v6,v7v_3, v_4, v_5, v_6, v_7 等中间变量,最终得到 yy。 【若需查看原始图片详情,请参考原文中的“Forward evaluation trace”计算图】
3.2.2 静态图 vs. 动态图

计算图的构建和执行方式可以分为静态图和动态图两种模式,各有优缺点。

  • 静态图(Static Graph):

    • 工作方式: 首先定义(构建)整个计算图的结构,然后编译并固化。在后续执行中,数据通过已定义的图流动,图结构本身不再改变。
    • 优点:
      • 全局优化: 由于图结构固定,可以在运行前对整个图进行全局优化,例如算子融合、内存预分配、跨设备调度等,从而获得更快的运算速度和更高的内存效率。
      • 部署友好: 编译后的图可以脱离Python运行时环境独立部署,适用于生产环境。
    • 缺点:
      • 调试不便: 图一旦构建完成,其内部执行流程是“黑盒”,难以在运行时查看中间结果或修改图结构,调试相对困难。
      • 灵活性差: 不适合处理动态的控制流(如条件判断、循环次数不确定的循环),这些会使图结构不固定。
    • 典型代表: TensorFlow 1.x 早期版本。
  • 动态图(Dynamic Graph):

    • 工作方式: 逐行执行代码,每个操作都会立即执行并产生结果。计算图是在运行时动态构建的,可以根据程序的逻辑实时修改图结构。
    • 优点:
      • 代码编写灵活: 像编写普通的Python程序一样,可以立即获得每一步的执行结果,便于进行实验和快速迭代。
      • 调试方便: 可以直接使用标准调试工具(如pdb)进行断点调试,观察变量的中间状态。
      • 支持动态控制流: 能够轻松处理条件判断和循环,因为图结构是动态生成的。
    • 缺点:
      • 优化不便: 由于图结构是动态变化的,难以进行全局性的编译时优化,可能导致运行效率低于静态图。
      • 部署挑战: 通常需要Python运行时环境,部署相对复杂。
    • 典型代表: PyTorch。
  • 代码示例对比(静态图 vs. 动态图):

    • TensorFlow 1.x (静态图):
      import tensorflow.compat.v1 as tf
      tf.disable_v2_behavior() # 禁用TF2.x行为以模拟TF1.x
      x = tf.placeholder(tf.float32, shape=(1, 2))
      y = tf.placeholder(tf.float32)
      w = tf.Variable(tf.random_normal((2, 1)))
      y_pred = tf.matmul(x, w)
      loss = y_pred - y
      grad_w = tf.gradients(loss, w)
      update_w = w.assign(w - alpha * grad_w[0]) # grad_w是列表
      with tf.Session() as sess:
          sess.run(tf.global_variables_initializer())
          for i in range(300):
              # 每一次iteration中重复执行同样的图
              sess.run([loss, update_w],
                       feed_dict={x: valuex, y: valuey})
    • PyTorch (动态图):
      import torch
      x = torch.randn(1, 2)
      y = torch.randn(1, 1)
      w = torch.randn(2, 1, requires_grad=True) # requires_grad=True 表示需要计算梯度
      for i in range(300):
          # 每一次iteration中构建并执行新图
          y_pred = x.mm(w) # 矩阵乘法
          loss = y_pred - y
          loss.backward() # 反向传播,计算梯度
          # loss.backward() # 再次执行将报错,因为梯度已计算并清空
          with torch.no_grad(): # 在此块内不计算梯度,避免影响w的梯度
              w -= 0.01 * w.grad # 手动更新权重
              w.grad.zero_() # 清空梯度,为下一次迭代做准备

    【若需查看原始图片详情,请参考原文中的“静态图 vs. 动态图”代码对比】

3.2.3 现有编程框架中的图模式

最初,深度学习框架在静态图和动态图之间存在明确的选择。然而,现代框架正朝着融合两者优点的方向发展。

  • 传统模式:
    • 静态图: TensorFlow 1.x, Caffe2。
    • 动态图: PyTorch。
  • 融合趋势:
    • PyTorch 2.x: 引入 torch.compile(),能够将动态图模式的代码编译成高效的静态图,从而结合了动态图的灵活性和静态图的性能优势。
    • TensorFlow 2.x: 默认采用动态图模式(eager execution),但通过 @tf.function 装饰器,可以将Python函数转换为可优化的静态图。
    • JAX: 天然使用Python语义(类似动态图),但通过 jit(Just-In-Time编译)将函数编译成静态图,实现高性能。
    • PaddlePaddle 和 MindSpore: 提供了同时支持动态图和静态图的模式。

3.3 自动微分方法

自动微分主要分为正向模式(Forward Mode)和反向模式(Reverse Mode),它们在计算梯度时遍历计算图的方向不同,因此在不同场景下有不同的效率特点。

3.3.1 正向自动微分
  • 原理: 正向自动微分(Forward Mode AD)按照计算图的正向拓扑顺序(从输入到输出)逐一计算每个中间变量的导数,最终得到输出相对于输入的导数。它基于链式法则,从输入变量开始,逐层计算中间变量对输入的偏导数。
  • 过程:
    • 为每个输入变量 xix_i 定义一个“种子”向量 xi˙\dot{x_i},通常只有一个元素为1,其余为0,表示只对该输入计算导数。
    • 从输入节点开始,按照正向传播顺序,计算每个节点输出值的同时,也计算其导数值。
    • 对于每个操作 z=f(x,y)z = f(x, y),同时计算 zzz˙=fxx˙+fyy˙\dot{z} = \frac{\partial f}{\partial x}\dot{x} + \frac{\partial f}{\partial y}\dot{y}
  • 缺陷:
    • 对于一个函数 F:RMRNF: \mathbb{R}^M \rightarrow \mathbb{R}^N,如果需要计算所有输出对所有输入的梯度矩阵(Jacobian矩阵),正向模式需要进行 MM 次正向AD遍历,每次遍历计算一个输出对所有输入的导数。
    • 效率问题: 当输出维度 N=1N=1(例如,损失函数通常是一个标量),而输入维度 MM 非常大时(例如,深度学习模型参数的数量),正向AD需要 MM 次独立的遍历。每次遍历都是为了计算代价函数对某个特定参数的偏导数,这些计算之间几乎没有复用。因为它们导数链条的前缀(prefix)不一样,无法有效复用计算。
    • 在深度学习中,我们通常关心单个代价函数(N=1N=1)对大量模型参数(MM 很大)的梯度。因此,正向自动微分在这种场景下效率低下。 【若需查看原始图片详情,请参考原文中的“正向自动微分方法”示意图】
3.3.2 反向自动微分
  • 原理: 反向自动微分(Reverse Mode AD)按照计算图的反向拓扑顺序(从输出到输入)逐一计算最终输出相对于每一个计算图节点的梯度。它也被称为反向传播(Backpropagation),是深度学习训练的基础算法。

  • 过程:

    • 从最终输出开始,其梯度通常设置为1(LL=1\frac{\partial L}{\partial L}=1)。
    • 逆向遍历计算图,对于每个操作,应用链式法则将“上游”梯度(Loutput\frac{\partial L}{\partial \text{output}})乘以“局部”梯度(outputinput\frac{\partial \text{output}}{\partial \text{input}}),从而得到“下游”梯度(Linput\frac{\partial L}{\partial \text{input}})。
    • 核心优势: 这种从输出端反向传播梯度的方式,能够高效地处理输出维度 N=1N=1 但输入维度 MM 很大的情况。因为所有变量的链式计算的后缀(suffix,即从当前节点到输出的路径)使用的梯度都是相同的,反向计算能有大量的计算复用。
  • 示例:多路径微分计算

    • 当一个变量(如 v1v_1)被多个后续操作(如 v2,v3v_2, v_3)使用时,其最终梯度是所有路径上梯度贡献的总和。
    • 在反向模式中,从输出开始计算梯度,当梯度传播到 v1v_1 时,来自 v2v_2 的梯度贡献和来自 v3v_3 的梯度贡献会被累加起来,从而一次性得到 v1v_1 的完整梯度。 【若需查看原始图片详情,请参考原文中的“微分的计算过程如果有多条路径如何?”示意图】
  • 偏伴随(Partial Adjoint):

    • 在反向自动微分中,为了更精确地描述多路径梯度累加的过程,引入了“偏伴随”的概念。
    • 定义: 对于计算图中任意一对输入输出节点 iijj,通过 iji \rightarrow j 的计算路径反向能够获得的关于 ii 的偏导数部分,即为“偏伴随”。
    • 求和: 一个变量的完整偏导数是其所有通过不同路径传播回来的“偏伴随”的总和。
    • 例如,在图 y=f(v2,v3)y = f(v_2, v_3) 中,计算 yv1\frac{\partial y}{\partial v_1} 需要将 yv2v2v1\frac{\partial y}{\partial v_2} \frac{\partial v_2}{\partial v_1}yv3v3v1\frac{\partial y}{\partial v_3} \frac{\partial v_3}{\partial v_1} 相加。 【若需查看原始图片详情,请参考原文中的“微分的计算过程如果有多条路径如何?”和“Define partial adjoint”示意图】
  • 完整的反向自动微分算法: 反向自动微分算法通常涉及以下步骤:

    1. 正向计算并保存中间结果: 执行一次正向传播,计算所有中间节点的值,并将这些值以及用于计算局部梯度的信息(如操作的输入)存储起来。
    2. 初始化输出梯度: 将最终输出节点(损失函数)的梯度初始化为1。
    3. 反向遍历计算图: 从输出节点开始,按照反向拓扑顺序遍历计算图。
    4. 应用链式法则: 在每个节点处,利用当前节点的上游梯度和局部梯度,计算其输入节点的梯度。
    5. 梯度累加: 如果一个节点有多个下游分支,来自这些分支的梯度贡献会在该节点处累加。 通过这种方式,可以高效地计算所有叶子节点(模型参数)相对于最终输出的梯度。

3.4 自动微分的图计算

将梯度计算过程也纳入计算图中,带来了额外的优势。

  • 将反向梯度传播加入计算图的优势:

    • 递归计算高阶梯度: 将梯度计算本身也视为图中的一系列操作,使得计算高阶梯度(例如,Hessian矩阵或二阶导数)变得自然而然,无需手动编写复杂的求导规则,只需递归地应用自动微分。
    • 性能优化: 在反向梯度传播过程中,需要使用正向传播产生的中间结果。如果将反向计算也融入到计算图中,框架可以利用计算图优化技术(如算子融合、内存管理)对梯度计算本身进行优化,提升性能。
    • 统一表示: 前向和反向计算都通过统一的计算图表示,简化了框架的设计和实现。
    • 现代深度学习框架实践: 在最新的深度学习框架中,通常都采取这种将反向梯度计算集成到计算图中的方案。 【若需查看原始图片详情,请参考原文中的“将反向梯度传播加入计算图中有何优势?”示意图,其右侧图示为将梯度计算也纳入计算图的方案】
  • 进一步了解:

    • 如需深入了解自动微分的理论和实践,可以参考论文:Automatic differentiation in machine learning: a survey (https://arxiv.org/abs/1502.05767)。

3.5 梯度检查点(Gradient Checkpointing)

梯度检查点(Gradient Checkpointing),也称为重计算(Recomputation),是一种优化技术,用于平衡深度学习模型训练过程中的内存使用和计算开销,尤其适用于训练大型模型。

3.5.1 内存与计算的权衡
  • 问题: 在反向传播过程中,为了计算梯度,通常需要保存正向传播时计算的所有中间激活值。对于非常深的神经网络,这些中间激活值会占用大量的内存。当模型过大导致内存不足时,就需要寻找解决方案。
  • 极端方案:
    • 全部保存: 保存所有正向传播(FP)的中间激活值,以便在反向传播(BP)时直接使用。这节省了计算,但消耗大量内存
    • 全部重新计算: 不保存任何中间激活值。在反向传播时,需要重新执行正向传播来计算所需的中间激活值。这节省了内存,但消耗大量计算
  • 梯度检查点目标: 在内存不足时,它提供了一种折衷方案,即用计算效率换取内存,以使得训练大型模型成为可能。
3.5.2 实现原理
  • 核心思想: 不保存所有中间激活值,只保存计算图中的**少量关键“检查点”**处的激活值。
  • 反向传播过程:
    1. 当反向传播到未保存激活值的层时,从最近的已保存检查点重新执行正向计算,生成所需的激活值。
    2. 这些重新计算的激活值只用于计算当前检查点和下一个检查点之间的梯度,一旦计算完成,便可以丢弃。
  • Checkpointing点选择:
    • 通常将模型分成若干段(segment),只在每段的起始点保存激活值。
    • 当计算某段的梯度时,从该段的起始检查点重新进行一次正向传播。
    • 在一次反向传播过程中,每个检查点段只会重新计算一次,并在计算该段的所有梯度时复用结果。
  • 优化策略:
    • 对于一个有 NN 层的网络,如果将 kk 个检查点均匀分布,选择 k=Nk = \sqrt{N} 可以达到一个较好的内存与计算平衡。
    • 这样,反向传播的总计算成本增加为正向传播成本的 2N\approx 2 \sqrt{N} 倍,而内存使用减少到 N\approx \sqrt{N} 倍。 【若需查看原始图片详情,请参考原文中的“Optimization: Gradient checkpointing”示意图】
3.5.3 进一步考量
  • 选择性保存: 一种快速应用梯度检查点的方法是,有选择地丢弃计算开销较低操作的结果,只保留那些计算耗时或内存占用大的操作的结果。
    • 例如,在卷积神经网络中,Conv-BatchNorm-Activation 流水线上:
      • 可以始终保留 卷积(Conv) 操作的结果,因为卷积的计算成本较高。
      • 可以丢弃 批归一化(BatchNorm)激活函数(Activation)池化(Pooling) 的结果,因为这些操作通常计算量较小,重新计算它们的代价相对较低。
  • 参考文献: Tianqi Chen等人在2016年的论文 Training Deep Nets with Sublinear Memory Cost (https://arxiv.org/abs/1604.06174) 详细阐述了这种次线性内存成本的训练方法。
3.5.4 计算图中的梯度检查点
  • 效果: 通过在计算图中应用梯度检查点技术,可以实现在内存中拟合尺寸大10倍的神经网络模型。
  • 代价: 这种内存节省通常伴随着大约 20% 的计算时间开销。
  • 应用示例: 该技术已成功应用于TensorFlow官方的CIFAR10模型等,显著提升了模型训练的可扩展性。 【若需查看原始图片详情,请参考原文中的“Gradient checkpointing in a computation graph”示意图】

4. 计算图执行

计算图(Computation Graph)是深度学习编程框架中描述模型计算逻辑的核心。在模型训练或推理时,这些计算图需要被实际执行。这一节将深入探讨计算图的执行过程、涉及的关键要素以及深度学习编译器在此过程中扮演的角色。

4.1 计算图执行要素

计算图的执行是将抽象的计算逻辑映射到具体硬件设备上并实际运行的过程。其核心要素包括:

  • 将计算图中的张量和操作(算子)映射到给定设备上具体执行:这是指将定义好的数学运算和数据流,根据其依赖关系,调度到CPU、GPU或其他加速器上进行计算。
  • 设备管理
    • 概念:深度学习框架需要有效管理异构计算资源(如CPU、多个GPU),包括设备的发现、初始化、内存分配和释放,以及任务在不同设备之间的调度。
    • 重要性:合理的设备管理能最大限度地利用硬件资源,提高计算效率。
  • 张量实现
    • 概念:张量是深度学习中的基本数据结构。框架需要提供高效的张量实现,包括内存布局(如行主序、列主序,或特定硬件友好的布局)、数据类型转换、以及在不同设备之间的数据传输机制。
    • 重要性:张量操作是深度学习计算的核心,其实现效率直接影响整体性能。
  • 算子执行
    • 概念:算子(Operator)是计算图中的节点,代表具体的数学运算(如卷积、矩阵乘法、激活函数等)。算子执行涉及根据算子类型和输入张量,调用对应的底层优化实现。
    • 获取算子执行序列:框架需要根据计算图的拓扑结构,确定算子的执行顺序,以满足数据依赖性。
    • 实现算子
      • 前端定义:算子在编程语言层面(如Python)的接口定义,供用户调用。
      • 后端实现:算子在底层硬件(如CUDA for GPU, AVX for CPU)上的高效实现。这些实现通常经过高度优化,以充分利用硬件特性。
      • 前后端绑定:将前端的算子调用映射到正确的后端实现。
    • 查找并调用算子:在运行时,框架根据计算图中的节点信息,查找并调用预编译好的或动态生成的底层算子实现。

4.2 深度学习编译器

随着深度学习模型复杂度的增加和硬件的多样化,深度学习编译器成为优化模型执行效率的关键技术。

4.2.1 定义与多层级优化

  • 什么是深度学习编译器?
    • 定义:它是一个接收以计算图形式表示的深度学习任务(模型),并能在指定的硬件平台上生成高性能代码的软件系统。
    • 核心目标:实现模型在不同硬件上的高性能执行,提高开发效率,并简化硬件后端适配。
  • 多层级优化:深度学习编译器通常在多个抽象层级进行优化,以全面提升性能。
    • 图层级优化
      • 关注点:主要关注计算图的结构和数据流,而不关心单个算子的具体实现细节。
      • 优化类型:包括子图替换、常量折叠、公共子表达式删除、布局优化以及算子融合等。这些优化操作在计算图层面重构、简化或合并计算,减少不必要的开销。
      • 对应章节:(在原始资料中提示为第四章内容,此处为本节后续部分)
    • 算子层级优化
      • 关注点:基于目标计算硬件的特点,对单个算子(如矩阵乘法、卷积)的实现内部进行性能提升。
      • 优化类型:每个算子可能有多种实现方式,例如不同的循环展开、tile 分块、内存布局、线程绑定方式等。算子层级优化旨在使算子在给定硬件上以最快速度运行(例如,在GPU上利用最大并行度)。
      • 对应章节:原始资料中提示为第五章内容。
  • 常见深度学习框架中所采用的编译技术和深度学习编译器
    • TVM:一个开源的深度学习编译器栈,支持多种硬件后端。
    • TC (Tensor Comprehensions):一个用于生成高性能张量运算代码的编译器。
    • XLA (Accelerated Linear Algebra):Google开发的线性代数编译器,TensorFlow 的后端之一。
    • MLIR (Multi-Level Intermediate Representation):Google开发的通用编译器基础设施,旨在统一不同领域的编译器技术。

4.2.2 编译优化流程示例

[图内容的文字描述]:原始资料中展示了一个“原始计算图”到“硬件平台”的编译优化流程。 原始计算图包含多个独立的3x3和1x1卷积操作。 经过图层级编译优化,这些操作被组合或简化。例如,独立的卷积操作可能被融合。 接着,通过算子层级编译优化,将优化后的图结构进一步转换为针对特定硬件(如CPU、GPU、DLP等)的高性能底层代码。 例如,一个Python中简单的矩阵乘法循环 for i in range(len(A)): for j in range(len(B[0])): for k in range(len(B)): C[i][j] += A[i][k] * B[k][j],在算子层级优化后,可能会被转换为包含SIMD指令(如_mm256_loadu_pd_mm256_broadcast_sd等)的C/C++代码,以充分利用硬件的并行计算能力。

这个示例形象地说明了编译器如何将高级抽象的计算图层层优化,最终生成高效的机器代码。 【若需查看原始图片详情,请参考原文中的“编译优化流程图”】

4.3 图层级编译优化

图层级编译优化是在计算图层面进行的,它不关心特定算子的具体执行过程,而重点关注数据在图中的流动方式。

4.3.1 优化方法概述

下表列出了常见的图优化方法及其说明:

优化方法说明
子图替换 (Subgraph rewriting)识别计算图中常见的算子组合(如 Conv + BN + ReLU),并将其替换为功能等价但经过高度优化的复合算子(或自定义融合算子)。这能减少开销,提高性能。
常量折叠 (Constant folding)在编译期计算出常量表达式的结果。例如,add(x, 2+3) 可以被优化为 add(x, 5),避免运行时重复计算常量。
公共子表达式删除 (CSE, Common Subexpression Elimination)如果计算图中存在两个或多个节点计算了相同的表达式,并且其输入也相同,则只保留其中一个计算结果,其他地方复用该结果。这可以消除冗余计算。
布局优化 (Layout optimization)调整张量在内存中的存储格式(例如,从 NCHW 转换为 NHWC,或反之),以适应不同后端硬件的内存访问模式和特性。合适的布局可以显著提升数据访问效率,特别是在使用 Tensor Core 等特殊硬件时。
算子融合 (Operator fusion)将多个连续的、计算量较小的算子(或同级的、相互独立的算子)融合成一个大的算子。这能有效减少 kernel 启动次数、内存访存开销以及中间结果的存储,从而提升整体性能。

4.3.2 子图替换

  • 原理:将原计算图中的节点或节点序列(计算操作)替换为功能等价但运算逻辑更优或已经高度优化的形式。
  • 示例:在 TensorFlow 中,常常通过人为设定的替换规则,识别并替换某些特定的子图模式,例如将 Conv + BatchNorm + ReLU 序列替换为一个单一的、优化过的融合算子。
  • 优势:通过替换为更高效的实现,可以减少计算开销,提高执行速度。

4.3.3 常量折叠与公共子表达式删除

  • 常量折叠 (Constant folding)
    • 原理:在编译阶段预先计算出所有常量表达式的结果,并用计算出的常量值替换表达式。
    • 示例:如果表达式中包含 16 * 16 * 224 这样的常量乘法,编译器会在编译时就计算出结果 57344,然后在运行时直接使用 57344,避免了不必要的乘法运算。
  • 公共子表达式删除 (CSE, Common Subexpression Elimination)
    • 原理:当同一个表达式在计算图的不同位置被多次计算,且其输入张量不变时,CSE 会识别出这些冗余计算,只保留第一次的计算结果,并在后续引用时直接使用该结果。
    • 示例: [图内容的文字描述]:原始资料中展示了 CSE 优化前后的计算图。 优化前:两个独立的 Matmul 操作都接收 Tensor 0Tensor 1 作为输入。 优化后:只保留一个 Matmul 操作,其输出同时作为 DivideSubtract 操作的输入,消除了冗余的 Matmul 计算。 【若需查看原始图片详情,请参考原文中的“公共子表达式删除示例图”】
    • 优势:减少了重复计算,节省了计算资源和时间。

4.3.4 代数化简与布局优化

  • 代数化简
    • 原理:将计算代价高的运算替换为等价的、计算代价低的运算。这通常基于数学恒等式或特殊情况。
    • 示例:如果表达式中出现乘以 0 的操作,编译器会直接将其结果判断为 0,而无需实际执行乘法运算,例如 A * 0 直接优化为 0
  • 布局优化
    • 原理:张量在内存中的存储布局会影响数据访问的效率,特别是对于并行计算硬件。布局优化旨在调整张量的存储格式以更好地适应硬件特性。
    • 背景:常见的张量布局有 NCHW (Batch, Channels, Height, Width) 和 NHWC (Batch, Height, Width, Channels)。不同的硬件或库可能对某种布局有更好的优化。
    • 示例:在使用 NVIDIA Tensor Core 进行计算时,采用 NHWC 格式的张量性能通常优于 NCHW 格式。编译器可以根据目标硬件自动转换张量布局以获得最佳性能。

4.3.5 算子融合(纵向)

  • 原理:将依赖关系链上的连续算子融合成一个更大的算子(也称为“Kernel”)。
  • 动机
    • 函数调用开销:每次调用一个算子(特别是在GPU上启动一个 Kernel)都有一定的开销。融合可以减少 Kernel 启动次数。
    • 内存访存开销:非融合操作可能需要将中间结果写回全局内存,然后再从全局内存读出供下一个算子使用。融合操作可以在片上缓存(如寄存器、共享内存)中直接传递中间结果,减少全局内存访问次数。
  • 示例: [图内容的文字描述]:原始资料中展示了纵向算子融合的示例。 融合前:一个 Multiply 算子和一个 Add 算子串联,Tensor 经过 Multiply 后产生中间结果,然后该中间结果再输入 Add 算子。 融合后:MultiplyAdd 被融合成一个 FMA (Fused Multiply-Add) 算子。 【若需查看原始图片详情,请参考原文中的“纵向算子融合示例图”】
  • 优势:显著减少了 Kernel 启动次数和全局内存读写,从而提升性能。

4.3.6 算子融合(横向)

  • 原理:将同级、相互独立的算子在同一个 Kernel 中融合计算。
  • 动机:当多个独立的计算具有相似的输入或在计算图的同一层级时,可以将它们合并到一个 Kernel 中,以利用数据局部性和并行性。
  • 示例: [图内容的文字描述]:原始资料中展示了横向算子融合的示例。 融合前:两个独立的 Matmul 算子,分别接收不同的输入 Tensor,但它们可能在计算图的同一层,并且没有直接的依赖关系。 融合后:这两个 Matmul 算子被融合成一个更大的“Fused Matmul”算子,在一个 Kernel 中并行或以更优的方式计算这两个矩阵乘法。 【若需查看原始图片详情,请参考原文中的“横向算子融合示例图”】
  • 优势:提高硬件利用率,减少多次 Kernel 启动和数据传输的开销。

4.3.7 算子融合的典型实现流程

算子融合的实现通常遵循以下步骤:

  1. 图遍历 (Graph Traversal)
    • 编译器首先遍历计算图的所有节点,分析它们之间的依赖关系、输入输出以及操作类型。
  2. 判断哪些节点可以融合
    • 纵向 fusion:识别连续依赖的节点序列,这些序列符合预定义的融合模式(如 Conv+BN+ReLU)。
    • 横向 fusion:识别在同一输入(或同一组输入)上操作的、在同一层级且相互独立的节点,它们可以被并行执行。
  3. 图变换 (Graph Transformation)
    • 替换节点:用一个新的融合算子或“fused kernel”来替换原来的一系列节点。这个新的算子包含了被融合的所有操作的逻辑。
    • 修改连接:更新新融合算子的输入输出连接,确保计算图的正确性。
    • 更新依赖:调整计算图的依赖关系,以反映融合后的结构。
  4. 生成最终中间表示和硬件可用的计算单元
    • 最终目标是生成一个优化的计算图,它可以进一步被编译成针对特定硬件的高性能机器代码。
  • 核心目标:通过上述步骤,有效减少内存访问次数Kernel Launch 次数,从而显著提升深度学习模型的执行性能。