原文:
zh.annas-archive.org/md5/c7aabb3a9c13924ec60749e96c9ff05f
译者:飞龙
第四章:分布式训练
本章内容包括
-
理解数据并行、模型并行和流水线并行
-
使用支持 Kubernetes 中数据并行训练的示例训练服务
-
使用多个 GPU 训练大型模型
在深度学习研究领域明显的一个趋势是通过更大的数据集和更大的模型以及越来越复杂的架构来提高模型性能。但更多的数据和更庞大的模型也会带来一些后果:它们会减慢模型训练过程以及模型开发过程。在计算中,性能常常与速度相抵触。例如,使用单个 GPU 训练一个 BERT(双向编码器表示转换器)自然语言处理模型可能需要几个月的时间。
为了解决数据集和模型参数规模不断增长的问题,研究人员创造了各种分布式训练策略。而主要的训练框架,如 TensorFlow 和 PyTorch,提供了实现这些训练策略的 SDK。借助这些训练 SDK,数据科学家可以编写跨多个设备(CPU 或 GPU)并行运行的训练代码。
在本章中,我们将从软件工程师的角度探讨如何支持分布式训练。具体来说,我们将看到如何编写一个训练服务来在一组机器上执行不同的分布式训练代码(由数据科学家开发)。
阅读完本章后,你将全面了解分布式训练如何从数据科学家和开发者的角度进行工作。你将了解到几种分布式训练策略和分布式训练代码模式,以及训练服务如何促进不同的分布式训练代码。
4.1 分布式训练方法的类型
有三种主要类型的分布式训练方法:模型并行、数据并行和流水线并行。模型并行 是一种将神经网络拆分为多个顺序子网络并在不同设备上运行每个子网络的策略。通过这种方式,我们可以使用一组 GPU 训练大型模型。
流水线并行 是模型并行的高级版本。模型并行的一个主要问题是在训练过程中只有一个 GPU 处于活动状态;其他 GPU 处于空闲状态。通过将每个训练示例批次划分为小的微批次,流水线并行可以在层之间重叠计算,以最大化 GPU 性能。这允许不同的 GPU 同时处理各种微批次。GPU 的训练吞吐量和设备利用率得到提高,从而比模型并行更快地进行模型训练。
数据并行 将数据集分成较小的部分,并让每个设备单独训练这些子数据集。因为现在每个设备训练的是较小的数据集,所以训练速度得到了提高。
将单设备训练代码转换为模型并行化或流水线并行化训练需要进行大量的代码改动,包括将神经网络分割为多个子网络,在不同的 GPU 上运行子网络,并将子网络的计算输出复制到不同的 GPU 上。这些改动的数量之多以及复杂性使得它们难以处理和调试。每个模型算法可能具有截然不同的模型架构,因此没有标准化的方法可以用于模型并行化或流水线并行化的模型分割。数据科学家必须逐个案例构建代码。
相反,数据并行化仅需要在单设备训练代码上进行最少的代码改动。而且,有标准化的模式可以将非分布式训练代码转换为数据并行化,而无需更改模型算法或架构。此外,数据并行化代码相对易于理解和调试。这些优点使得数据并行化成为我们分布式训练的首选。
尽管数据并行化有很多优点,模型并行化和流水线并行化也各自具有自己的优势和用途。例如,当您有无法适应一个 GPU 的大型模型时,它们是最佳的分布式解决方案。我们将在第 4.4 节中更详细地讨论它们。
4.2 数据并行化
在本节中,我们将研究数据并行化理论及其并行执行的挑战,以及 PyTorch、TensorFlow 和 Horovod 中的示例训练代码。
4.2.1 理解数据并行化
数据并行化涉及到一组训练设备在一个大数据集上一起工作。通过让每个设备处理数据集的一个子集,我们可以大大减少训练时间。
同步数据并行化是最常采用的数据并行化方法。它将模型网络复制到训练组中的每个设备上,无论是 GPU 还是 CPU。数据集被分割成小批量,并在所有设备上(再次是 CPU 或 GPU)上分发这些批量。训练步骤同时进行,每个设备上使用不同的小批量;因此,设备充当自己的数据分区。在计算梯度以更新神经网络时,算法通过从每个设备聚合梯度来计算最终梯度。然后,它将聚合梯度分发回每个设备,以更新其本地神经网络。虽然每个设备上的训练数据集是不同的,但这些设备上的本地神经网络是相同的,因为它们在每个训练迭代中都是由相同的梯度更新的。因此,这个过程被称为同步数据并行化。
您可以在图 4.1 中可视化这个过程。该图比较了在单个 GPU 上进行深度学习训练的过程(左侧的图(a))与使用三个 GPU 进行同步数据并行训练的设置(右侧的图(b))。
图 4.1 同步数据并行概念图。(a)在单 GPU 上进行深度学习训练。(b)使用三个 GPU 进行同步数据并行训练。
通过比较图(a)和(b),您会发现与单设备训练相比,同步数据并行引入了两个额外步骤。第一个额外步骤是将一个训练批次分成三个小批次,这样每个设备就可以处理自己的小批次。第二步是同步来自所有机器的聚合梯度,以便它们在更新本地模型时都使用相同的梯度。
注意:为了聚合不同工作者计算的梯度,您可以使用算法 all-reduce。这是一种流行的算法,它独立地将所有进程的数据数组组合成一个单一数组。在“使用 PyTorch 编写分布式应用程序”(pytorch.org/tutorials/intermediate/dist_tuto.html
)中,您可以找到 PyTorch 支持 all-reduce 算法的示例。
从实现的角度来看,数据并行只需要对单设备模型训练过程进行最少的更改。其主要开销是添加了梯度聚合的步骤。
模型参数更新:同步 vs.异步
对于在数据并行中跨工作者聚合梯度有两种思路:同步更新和异步更新。让我们分别看看它们是如何工作的,以及它们的优点和缺点,这样您就可以自行选择:
-
同步模型更新—如图 4.1 所示,同步模型更新在梯度同步步骤暂停训练迭代,直到所有设备接收到聚合梯度。然后它继续下一步,更新模型参数。通过这种方式,所有设备在同一时间获得相同的梯度更新,从而确保每个工作者的模型在每个训练迭代中都是一致的。同步模型更新的问题是显而易见的:当梯度在工作者之间同步时,训练迭代被阻塞,因此没有一个工作者可以开始处理下一个数据小批次。如果存在一些慢机器或网络问题,则整个分布式工作组都会受阻,而较快的工作者则处于空闲状态。
-
异步模型更新 — 与之相反,异步模型更新方法不强制每个训练设备或工作器等待接收来自其他设备的梯度。相反,每当一个设备完成梯度计算后,它立即更新本地模型而无需检查其他设备。每个设备都独立工作,尽管它的梯度仍然需要复制到每个其他设备,但这些更新的同步不是必要的。异步方法可能看起来很吸引人;它简单,并且可以比同步方法每分钟运行更多的训练步骤。异步方法的缺点是训练时间较长,并且产生的模型比同步模型更新方法产生的模型不准确。
当我们使用异步方法时,梯度在不同设备上独立计算。一些机器运行速度更快,而其他机器运行速度较慢;因此,这些梯度可以来自每个设备的不同训练迭代。因此,无法保证聚合的梯度将指向最佳方向。例如,假设来自慢速机器的梯度是从训练迭代 5 计算的,而其他更快的机器已经移动到训练迭代 20。当我们聚合所有工作器的梯度时,低迭代的梯度会应用于高迭代的梯度;这会降低梯度质量。
此外,异步方法通常收敛速度较慢,并且比同步方法有更高的准确度损失。因此,今天大多数数据并行库都在执行同步模型更新。在本章中,当我们提到数据并行和其代码实现时,我们指的是同步数据并行。
数据集和模型的内存约束
在深度学习中,数据集和模型在训练过程中消耗计算实例的大部分内存。如果训练数据或神经网络(模型)超出了本地设备的内存限制,训练过程将被终止,出现内存不足(OOM)错误。数据并行旨在提高训练速度,但不能解决内存约束问题。
对于由加载数据集引起的 OOM,我们可以减少训练数据的批量大小,因此训练过程在每个训练循环中加载较小量的数据到本地内存中。在数据并行背景下,我们需要确保小批量训练数据可以适合每个工作器设备的内存。
对于由模型大小引起的 OOM,我们需要采用模型并行或管道并行(见 4.4 节)。当神经网络(模型)的大小超过单个设备的内存限制时,数据并行简单地无法工作。
4.2.2 多工作器训练挑战
容错性和带宽饱和是我们作为软件开发者在执行训练服务中的数据并行代码时需要解决的两个挑战。 解决这两个挑战对于降低运营成本和改善数据并行性训练的性能至关重要。
容错性
我们不希望整个分布式训练组因为一个工作节点意外失败而全部失败。 这不仅会导致服务可用性问题,还会增加我们的训练成本,因为如果一个节点失败,所有其他节点的工作都会被浪费。
为了提高容错性,我们可以在每个工作节点的远程文件系统中保留每个训练步骤(即模型参数)的训练状态。 然后,如果一个工作节点失败或花费太长时间来完成一个训练迭代,我们可以重新启动该工作节点并加载其最近的先前状态。
TensorFlow 和 PyTorch 框架都具有备份和恢复功能。 作为训练服务开发者,我们可以设置远程磁盘或备份存储系统,并将访问配置传递给训练容器。 然后,在训练过程中,训练代码可以使用外部文件系统来备份或恢复状态。
带宽饱和
向分布式训练组添加更多的 GPU 和更多的机器并不总是会提高性能。 无论我们使用同步还是异步模型更新,算法都必须在每个训练迭代结束时在训练节点之间通信梯度或模型参数。 在 GPU RAM 和网络之间移动数据所花费的时间最终将超过通过分割训练工作负载获得的加速。
因此,在数据并行达到最佳性能之前,可以并行发生多少个实例存在上限。 这一限制由模型参数的数量和模型的密度(模型权重中的非零值有多少)确定。 如果是一个大型、密集的模型,有大量的参数和梯度需要传输,那么它的饱和度就比较大,大于一个较小的模型或一个大型的稀疏模型。
有一些推荐的并行实例数,例如,对于神经机器翻译,在 8 个 GPU 上可以实现 6 倍的加速,对于 ImageNet 模型,在 50 个 GPU 上可以实现 32 倍的加速。 但是,我们需要通过我们自己的实验来确定最佳实验点,因为 GPU 和模型架构都在快速发展,标准推荐很快就会过时。 作为平台开发者,除了选择最佳的并行工作节点数量外,我们还有三种额外的方法来减轻带宽饱和。
首先,我们可以将并行工作者(即容器或 Pod)分组到更少的机器中,以减少网络跳数。例如,在 Kubernetes 中,您可以设置具有亲和性和反亲和性规则的nodeSelector
(mng.bz/qo76
),以在一些选择的具有更好网络和更多计算能力的服务器上提供训练实例(Kubernetes Pod)。
第二个选择是始终将训练映像升级为使用训练框架的最新版本。诸如 PyTorch、TensorFlow 等流行框架不断发展,以减少网络中传输的数据量以进行分布式训练。注意发布说明并利用这些改进。
最后,不要低估初始化分布式组时进行微小调整可能带来的收益。例如,考虑使用 PyTorch。PyTorch 数据并行库将神经网络参数梯度分区为桶,然后在梯度同步步骤期间将桶发送到工作进程中。桶的大小决定了一次在不同设备之间传输多少数据。因此,通过选择合适的桶大小,我们可以确定设备饱和和网络饱和之间的最佳训练速度的甜蜜点。桶的大小可以在 PyTorch 分布式数据并行(DDP)组件的构造函数中配置(mng.bz/7ZB7
)。
4.2.3 为不同的训练框架编写分布式训练(数据并行性)代码
在本节中,您将看到一些用于数据并行分布式训练的训练代码片段,涵盖了三个训练框架:TensorFlow、PyTorch 和 Horovod。如果这里的代码示例难以解析,不用担心。目的是体验数据科学家如何处理分布式训练。这将让您了解训练服务如何实现分布式训练。
PyTorch
PyTorch 框架具有 DDP 库,该库在模块级别实现数据并行性。DDP 包装模型对象,使其可以在多台机器上无缝运行。其训练进程可以放置在同一台机器上,也可以分布在多台机器上。
要将单设备/进程训练代码转换为数据并行-分布式训练代码,我们需要进行以下两个修改。首先,我们必须通过允许每个训练进程向主进程注册自己来初始化训练组。其中一个进程声称自己是主进程,而其他进程声称自己是工作进程。每个训练进程将在此注册阶段等待,直到所有工作进程加入分布式组。
要注册一个进程,我们需要知道总的训练进程数(world_size
),该进程的唯一 ID(rank
)以及主进程的地址(在环境变量中定义MASTER_ADDR
和MASTER_PORT
)。如下所示查看代码示例:
def setup(rank, world_size):
os.environ['MASTER_ADDR'] = 'xxxx'
os.environ['MASTER_PORT'] = 'xxx'
# initialize the process group, "gloo" is one of the communication
# backends Pytorch supports, it also supports MPI and NCCL.
# rank is the process’s rank, it's a globally unique id
# for this process. rank=0 means master process.
# world_size is the total number of processes in this training group.
dist.init_process_group("gloo", rank=rank, world_size=world_size)
def cleanup():
dist.destroy_process_group()
其次,我们使用 DDP 类来包装模型对象。PyTorch DDP 类将处理分布式数据通信、梯度聚合和本地模型参数更新:
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
# create model and move it to GPU
model = DpModel().to(device)
# wrap the model with DDP
ddp_model = DDP(model, device_ids=[rank])
outputs = ddp_model(data) ❶
# compute the loss and sync gradient with other workers.
# when 'backward' function returns, the param.grad already
# contains synchronized gradient tensor
loss_fn(outputs, labels).backward()
❶ DDP 包装器负责分布式训练的执行。
对于高级用例,PyTorch 库提供了 API,因此您可以在较低的级别实现自己的梯度同步函数。您可以在官方教程“使用 Pytorch 编写分布式应用程序”(mng.bz/m27W
) 中查看详细信息。
TensorFlow/Keras
TensorFlow 支持与 PyTorch 非常相似的分布式训练方式;它首先定义分布式训练策略(例如 MultiWorkerMirroredStrategy
),然后使用该策略初始化模型。为了让策略识别分布式组中的工作节点,我们需要在每个训练进程中定义 TF_CONFIG
环境变量。TF_CONFIG
包含一个工作节点的唯一 ID 和组中所有其他工作节点的地址。请参阅以下代码:
# Step 1: define 'TF_CONFIG' environment variable to describe
# the training group and the role for the process.
# The worker array defines the IP addresses and ports of
# all the TensorFlow servers used in this training.
tf_config = {
'cluster': {
'worker': ['192.168.4.53:12345', '192.168.4.55:23456']
},
# A 'task' provides information of the current task and is
# different for each worker. It specifies the 'type' and
# 'index' of that worker.
'task': {'type': 'worker', 'index': 0}
}
os.environ['TF_CONFIG'] = json.dumps(tf_config)
# Step 2: define distributed training strategy,
# the MultiWorkerMirroredStrategy takes
# care of the synchronous data parallel distributed training.
strategy = tf.distribute.MultiWorkerMirroredStrategy()
global_batch_size = per_worker_batch_size * num_workers
multi_worker_dataset = mnist.mnist_dataset(global_batch_size)
# Step 3: start the distributed training.
with strategy.scope():
# Model building/compiling need to be within 'strategy.scope()'.
multi_worker_model = mnist.build_and_compile_cnn_model()
multi_worker_model.fit(multi_worker_dataset,
epochs=3, steps_per_epoch=70)
Horovod
Horovod 是一个单一目的的分布式框架。与可以用于一系列任务的 TensorFlow 和 PyTorch 相比,例如数据处理、模型训练和模型服务,Horovod 只能专注于一个任务:使分布式深度学习训练变得快速且易于使用。
Horovod 最大的优势在于它可以与不同的训练框架一起使用,例如 TensorFlow、Keras、PyTorch 和 Apache MXNet。因此,我们可以以一种方式(Horovod 方式)配置我们的训练集群,以运行 PyTorch、TensorFlow 和其他框架的分布式训练。这里,我们只列出了两个代码片段,用于使用 TensorFlow 和 PyTorch 与 Horovod,但您可以在 Horovod 的网站上查看其他框架的示例。
让我们看看 TensorFlow 的示例。为了设置数据并行 ism-分布式训练,首先我们初始化 Horovod 训练组,它将自动找到集群中的其他 Horovod 节点。接下来,我们将 rank 0(主工作节点)的初始变量状态广播到所有其他进程。这将确保所有工作节点的一致初始化。然后我们将梯度磁带包装在分布式梯度磁带中,这将对所有工作节点上的梯度进行平均。其余的代码只是普通的 TensorFlow 训练代码。因此,请参阅以下代码(github.com/horovod/horovod/blob/master/examples
):
hvd.init() ❶
.. .. ..
@tf.function
def training_step(images, labels, first_batch):
with tf.GradientTape() as tape:
probs = mnist_model(images, training=True)
loss_value = loss(labels, probs)
# Wrap tape with Horovod Distributed GradientTape.
# This gradient tape averages gradients from all
# workers by using allreduce or allgather, and then
# applies those averaged gradients back to the local model.
tape = hvd.DistributedGradientTape(tape)
grads = tape.gradient(loss_value, mnist_model.trainable_variables)
opt.apply_gradients(zip(grads, mnist_model.trainable_variables))
# Broadcast initial variable states
# from rank 0 to all other processes.
if first_batch:
hvd.broadcast_variables(mnist_model.variables, root_rank=0)
hvd.broadcast_variables(opt.variables(), root_rank=0)
return loss_value
for batch, (images, labels) in \ ❷
enumerate(dataset.take(10000 / hvd.size())):
loss_value = training_step(images, labels, batch == 0)
.. .. ..
# save checkpoints only on worker 0 to
# prevent other workers from corrupting it.
if hvd.rank() == 0:
checkpoint.save(checkpoint_dir)
❶ 初始化 Horovod
❷ 根据 GPU 数量调整步数
以下代码是使用 PyTorch 与 Horovod 的示例。一些 PyTorch Horovod API 与 TensorFlow 不同,例如 hvd.DistributedOptimizer
与 hvd.DistributedGradientTape
。但是这些 API 来自相同的 Horovod SDK 并在幕后共享相同的工作节点机制。让我们看看 PyTorch 代码片段:
# Horovod: initialize Horovod.
import torch
import horovod.torch as hvd
# Initialize Horovod
hvd.init()
.. .. ..
# Build model...
model = ...
optimizer = optim.SGD(model.parameters())
# Add Horovod Distributed Optimizer, this is equal
# to hvd.DistributedGradientTape(tape)
# for Tensorflow2
optimizer = hvd.DistributedOptimizer(optimizer,
named_parameters=model.named_parameters())
# Broadcast parameters from rank 0 to
#all other processes.
hvd.broadcast_parameters(model.state_dict(),
root_rank=0)
for epoch in range(100):
for batch_idx, (data, target) in enumerate(train_loader):
optimizer.zero_grad()
output = model(data)
loss = F.nll_loss(output, target)
loss.backward()
optimizer.step()
.. .. ..
尽管模型在两个不同的框架中定义——TensorFlow 2 和 PyTorch——但从这两个代码片段我们可以看出它们都使用同样的 Horovod SDK 来运行分布式训练。这里的好处在于我们可以使用一个标准方法(即 Horovod 方式)在我们的训练集群中设置分布式工作组,并且它仍然可以用于不同训练框架中编写的训练代码。
关于训练代码的两个要点
如果你在阅读这些训练代码片段时感到困惑,那没关系。作为训练服务开发人员,你不需要编写这些代码片段。我们想要从这次讨论中强调两点:
-
尽管本节中的代码示例使用不同框架和不同 API 实现分布式训练,但代码遵循第 4.2.1 节中描述的相同数据并行范式。也就是说,代码始终(1)为每个并行训练进程设置通信组,(2)配置模型对象以在所有工作节点上聚合梯度。因此,作为开发人员,我们可以使用统一的方法为不同的训练框架设置和管理分布式训练进程。
-
将模型训练代码从单设备训练扩展到数据并行分布式训练的工作相对较为琐碎。如今,分布式训练框架/SDK 非常强大,我们不需要实现数据并行的每一个细节,比如同步梯度,它会在整个网络中同步梯度。训练框架和 SDK 处理这些过程,使它们运行无缝。分布式数据并行训练代码几乎与单设备训练代码相同,除了配置训练组时。
4.2.4 数据并行分布式训练中的工程努力
那么在生产环境中启用数据并行分布式训练的工作是什么样子的呢?首先,它需要数据科学家和服务开发人员的联合工程努力。对于数据科学家来说,他们需要升级单设备训练代码以分布式运行,使用类似前一节中的代码片段。与此同时,服务开发人员必须增强训练服务,以自动设置分布式工作组,以允许进行分布式训练。
为了使训练服务用户友好,服务应该整合不同分布式训练框架的设置细节。因此,数据科学家只需定义他们在训练中所需的并行实例数。
让我们以 TensorFlow 分布式训练为例。从我们在第 4.2.3 节中的讨论中可以看出,每个设备上的 TensorFlow 训练代码必须将tf_config
(见下面的示例)设置为环境变量。这样,在训练过程中底层的 TensorFlow 分布式库就知道如何与其他训练进程通信了:
tf_config = {
'cluster': {
'worker': ['192.168.4.53:12345', '192.168.4.55:23456']
},
# A 'task' provides information of the current task
# and is different for each worker. It specifies
# the 'type' and 'index' of that worker.
'task': {'type': 'worker', 'index': 0}
}
从可用性的角度来看,我们不能指望数据科学家为每个分布式训练进程找出设置值——服务器 IP 地址和任务索引,特别是如果整个训练组都是动态分配的。一个训练服务应该自动为分布式训练请求创建一组计算资源,用正确的 IP 地址初始化分布式训练库,并启动训练进程。
图 4.2 是支持分布式训练的训练服务的概念图。从图中可以看到,数据科学家 Alex 发送了一个训练请求来启动分布式训练任务。然后服务(由服务开发者 Tang 构建)生成两个工作机器并分布式执行训练代码。除了准备训练代码外,Alex 还可以为训练任务指定配置,例如并行工作进程的数量和分布式训练框架的类型(TensorFlow、PyTorch 或 Horovod)。
图 4.2 分布式训练系统概览
让我们缓慢地浏览一下这张图,以更好地了解系统是如何设置的,以及谁做了什么工作。我们看到,作为工程师的 Tang 需要进行三项增强——在图 4.2 中编号为 1、2 和 3——来将训练服务从单设备训练器(正如我们在第三章中所见)转变为数据并行的分布式训练器。
第一步是更新训练服务,以在运行时按需构建分布式训练组。当服务收到分布式训练请求时,它从训练集群中为训练任务分配多个工作进程,并将训练代码分发给每个工作进程。
第二步是以编程方式为每个训练进程初始化正确的服务器 IP、端口号和训练进程 ID。这确保了分布式库(通常称为框架,比如 TensorFlow)具有足够的信息来为训练组建立内部工作进程之间的通信。正如我们在前一节中看到的,对于每个分布式训练框架,设置配置都有所不同。训练服务应该知道如何为各种框架建立内部工作进程之间的通信,这样数据科学家就可以专注于算法开发,而不用担心底层基础架构。
第三步是为每个工作节点提供远程存储,以备份和恢复其训练状态。在分布式训练中,如果一个工作节点失败,整个训练组失败,并且将浪费大量计算资源。因此,在发生硬件故障或网络问题时,使分布式训练组能够恢复是至关重要的。通过提供远程存储和备份 API,分布式训练进程可以在每个训练迭代后保存其训练状态(神经网络)。当训练过程在训练中间发生故障时,可以恢复其先前的状态并重新开始,整个训练组也将继续进行。
注意:如果你想了解更多关于数据并行的知识,你可以从以下两篇文章开始:来自 O’Reilly 的博客文章“Distributed TensorFlow: Reduce both experimentation time and training time for neural networks by using many GPU servers” by Jim Dowling(www.oreilly.com/content/distributed-tensorflow/),以及来自 Google Brain 的一篇论文“Revisiting Distributed Synchronous SGD” by Chen et al.(arxiv.org/pdf/1604.00981.pdf
)。
4.3 支持数据并行-分布式训练的示例服务
在本节中,我们将扩展上一章(第 3.3 节)介绍的示例服务,以支持数据并行-分布式训练。
4.3.1 服务概述
与第 3.3 节讨论的单设备训练相比,用户工作流程保持不变。数据科学家 Alex 首先构建模型训练代码,并向训练服务发送训练请求。然后,服务运行实际训练,并在最后生成模型。
不过,有一些关键区别。首先,Alex 升级了意图分类训练代码,使其能够适用于单设备和多设备。其次,服务开发者 Tang 修改了训练服务 API,提供了一个新的参数PARALLEL_INSTANCES
。该参数允许 Alex 为分布式训练设置工作组的大小。
要正确管理服务器集群,我们需要 Kubernetes 的帮助。Kubernetes 可以在工作资源分配和工作节点通信方面为我们节省大量努力。因此,我们引入了一个新组件——Kubernetes 作业追踪器,用于在 Kubernetes 中管理训练作业。你可以在图 4.3 中看到更新后的服务设计图和用户工作流程。
图 4.3(a):之前在图 3.5 中介绍的训练服务设计;(b) 在 Kubernetes 上具备分布式训练支持的更新服务设计
图 4.3 (a) 重复了我们在第 3.3 节讨论的训练服务的系统图,该系统使用 Docker 作业跟踪器在 Docker 引擎中运行训练作业。图 4.3 (b) 可视化了现在支持分布式训练的更新后的训练服务,包括 Kubernetes 和 Docker 引擎后端。Kubernetes 作业跟踪器被添加以在 Kubernetes 集群中运行分布式训练作业。该组件通过启动 Kubernetes pod 来执行训练作业,并监视和更新内存存储中的作业执行状态。
我们还对意图分类 PyTorch 训练代码进行了一些更改,以便可以分布式运行。我们将在稍后的第 4.3.5 节中简要回顾这一点。
一个很好的时间节省者是,我们不需要更改已经创建的服务 API 接口(第 3.3.3 节)。我们的用户可以简单地使用相同的 API 在 Docker 引擎和 Kubernetes 集群中训练模型。这符合我们在第三章中介绍的训练服务原则之一(第 3.1.2 节):使用统一的 API 并使其在后端实现上保持不可知性。
4.3.2 与服务交互
首先,让我们使用 Kubernetes 后端运行训练服务;请参见以下命令(scripts/ts-001-start-server-kube.sh
):
$ docker build -t orca3/services:latest -f services.dockerfile .
$ docker run --name training-service -v \
$HOME/.kube/config:/.kube/config --env \ ❶
APP_CONFIG=config-kube.properties \
--network orca3 --rm -d -p
"${TS_PORT}":51001
orca3/services:latest training-service.jar
❶ 本地 Kubernetes 配置
注意 本节仅包含运行示例服务所需的主要步骤和关键命令。因此,可以清晰地演示概念,而不需要冗长的代码页和执行输出。如果您想在本节运行实验,请按照 orca3/MiniAutoML 存储库中的 “Distributed trainer training demo”(github.com/orca3/MiniAutoML/blob/main/training-service/distributed_trainer_demo.md)文档中的说明进行操作。
一旦训练服务容器正在运行,我们就可以提交一个训练 gRPC 请求。尽管服务现在在 Kubernetes 后端运行,但训练 API 仍然保持不变。与我们发送给 Docker 后端演示的训练请求(请参见第 3.3.1 节)相比,请求有效负载中仅添加了一个额外参数 — PARALLEL_INSTANCES=3
。这告诉训练服务创建一个包含三个工作节点的分布式训练组来训练模型。如果我们将此参数设置为 1,则是单设备训练请求。查看以下代码片段以提交一个包含三个并行实例的分布式训练请求(scripts/ts-004-start-parallel-run.sh
1
):
# submit a distributed training request
$ grpcurl -plaintext -d "{ "metadata":
{ "algorithm":"intent-classification",
"dataset_id":"1",
"Name":"test1",
"train_data_version_hash":"hashBA==",
"Parameters":{
"LR":"4","EPOCHS":"15",
"PARALLEL_INSTANCES":"3", ❶
"BATCH_SIZE":"64","FC_SIZE":"128"}}
}"
${TS_SERVER}:${TS_PORT}
training.TrainingService/Train
❶ 需要一个包含三个工作节点的训练组
要检查训练执行的进度,我们可以使用 GetTrainingStatus
API:
grpcurl -plaintext -d "{"job_id": "$1"}" ❶
${TS_SERVER}:"${TS_PORT}"
training.TrainingService/GetTrainingStatus
❶ 提供作业 ID 以查询状态
除了查询训练服务 API 以获取作业执行状态外,我们还可以在 Kubernetes 中检查训练进度。使用 Kubernetes 命令kubectl
get
all
,我们可以看到在本地 Kubernetes 环境中创建了三个工作 Pod。其中一个是主工作 Pod,另外两个是普通工作 Pod。还创建了一个 Kubernetes 服务对象 intent-classification-1-master-service
用于主工作 Pod,它使主 Pod 和工作 Pod 之间具有网络连接性。代码段如下:
# check Kubernetes resources status.
# We could see a distributed training group contains
# with three pods and one service are created in Kubernetes
$ kubectl get all -n orca3
NAME READY STATUS
pod/intent-classification-1-1-worker 0/1 Completed ❶
pod/intent-classification-1-2-worker 0/1 Completed
pod/intent-classification-1-master 0/1 Completed ❷
NAME TYPE .. ..
service/intent-classification-1-master-service ClusterIP ❸
❶ 工作 Pod 的其中之一
❷ 控制训练 Pod
❸ 用于训练 Pod 通信的 Kubernetes 服务
4.3.3 启动训练作业
现在,让我们来看看使用 Kubernetes 后端启动训练作业的工作流程。当接收到训练请求时,将该请求添加到作业队列中。同时,Kubernetes 作业跟踪器会监视作业队列。当跟踪器发现等待的作业并且系统具有可用的容量时,它将开始处理这些作业。
要启动一个 PyTorch 分布式训练作业,跟踪器首先创建所需数量的 Kubernetes Pod。每个 Pod 托管一个训练进程。跟踪器还向每个 Pod 传递独立的参数,然后将作业从作业队列移动到启动列表(图 4.4)。
图 4.4 显示了在 Kubernetes 中启动训练作业的工作流程:第 1 步,在作业队列中检测等待的作业;第 2 步,创建 Kubernetes Pod 来运行训练;第 3 步,将作业从作业队列移动到启动列表。
在图 4.4 中,Kubernetes 作业跟踪器可以处理单设备训练和分布式训练。它为单设备训练创建一个 Kubernetes Pod,并为分布式训练创建多个 Pod。
一个 Kubernetes 作业跟踪器类似于一个 Docker 作业跟踪器,运行一个训练 Pod。它将所有用户定义的参数封装在环境变量中,并将它们传递到 Kubernetes Pod。
要使用多个 Pod 设置 PyTorch 分布式训练,该服务处理两个功能。首先,它创建一个 Kubernetes 服务对象来与主 Pod 通信。从 PyTorch 分布式训练算法部分(4.2.3)我们知道,每个 PyTorch 训练过程都需要主过程(Pod)的 IP 地址来初始化分布式训练组。例如,在训练逻辑开始之前,每个 PyTorch 代码需要具有以下代码片段:
def setup(rank, world_size):
os.environ['MASTER_ADDR'] = 'xxx xxx xxx xxx'
os.environ['MASTER_PORT'] = '12356'
dist.init_process_group("gloo", ❶
rank=rank, world_size=world_size) ❶
❶ 加入到分布式组中的当前进程,通过寻找主 Pod
但是在 Kubernetes 中,pod 是一个短暂的资源,所以我们不能依赖 pod 的 IP 地址来定位一个 pod。相反,我们使用 Kubernetes 域名服务 (DNS) 作为永久地址来定位 pod。即使 pod 在不同节点被销毁和重建,IP 不同,我们仍然可以使用相同的 DNS 来达到它。所以,为了启用训练组的初始化,我们首先为主 pod 创建一个 Kubernetes 服务,然后将 DNS 传递给所有工作节点作为主 pod 地址。
其次,它向每个 pod 传递了四个环境变量。每个训练 pod 需要的四个变量是 WORLD_SIZE
、RANK
、MASTER_ADDR
和 MASTER_PORT
:
-
WORLD_SIZE
表示训练组中包括主节点和工作节点在内的总 pod 数。 -
RANK
是一个训练过程的唯一 ID;主进程的 rank 必须为 0。 -
MASTER_ADDR
和MASTER_PORT
定义了主进程的主机地址和端口号,因此每个工作节点可以用它们来达到主 pod。
举例来说,当使用三个实例进行分布式训练时,我们为每个 pod 创建三个环境变量(一个主节点,两个工作节点):
Master Pod:
WORLD_SIZE:3; RANK:0,
MASTER_ADDR: intent-classification-1-master-service,
MASTER_PORT: 12356
Worker Pod 1:
WORLD_SIZE:3; RANK:1,
MASTER_ADDR: intent-classification-1-master-service,
MASTER_PORT: 12356
Worker Pod 2:
WORLD_SIZE:3; RANK:2,
MASTER_ADDR: intent-classification-1-master-service,
MASTER_PORT: 12356
综合以上各种解释,让我们一起来看看实际代码是如何实现的。以下列表突出了在 Kubernetes 中如何实现启动分布式训练。
列表 4.1 启动分布式训练作业
protected List<String> launchTrainingPods(
int jobId, int worldSize, TrainingJobMetadata metadata, .. ..) {
.. .. ..
// It's a distributed training if the worldSize is greater than 1.
if (worldSize > 1) { ❶
// .. .. ..
api.createNamespacedService( ❷
config.kubeNamespace, serviceBody, ❷
null, null, null); ❷
serviceTracker.add(masterServiceName);
logger.info(String.format("Launched master service %s", masterServiceName));
.. .. ..
}
// create training pods definition
for (int rank = 0; rank < worldSize; rank++) {
envs.put("WORLD_SIZE", Integer.toString(worldSize)); ❸
// RANK 0 is master
envs.put("RANK", Integer.toString(rank)); ❸
envs.put("MASTER_ADDR", masterPodDnsName); ❸
envs.put("MASTER_PORT", Integer.toString(masterPort)); ❸
V1PodSpec podSpec = new V1PodSpec() ❹
.restartPolicy("Never") ❹
.addContainersItem(new V1Container() ❹
.image(algorithmToImage( ❹
metadata.getAlgorithm())) ❹
.env(envVarsToList(envs)) .. .. ..
String workerPodName = rank == 0 ? masterPodName :
String.format("job-%d-%d-%s-worker-%d", jobId,
now, metadata.getName(), rank);
V1Pod workerPodBody = new V1Pod();
workerPodBody.apiVersion("v1");
.. .. ..
// (3)
api.createNamespacedPod(config.kubeNamespace, ❺
workerPodBody, null, null, null); ❺
.. .. ..
}
return podNames;
}
❶ World size >1: 表示这是一个分布式训练
❷ 创建一个 Kubernetes 服务,并指向主 pod
❸ 设置与分布式训练相关的配置作为环境变量
❹ 定义 pod 配置;将训练参数作为环境变量传递
❺ 创建实际的训练 pod
RANK 值不一定一一对应于 pod
RANK
是分布式训练中的一个棘手变量。请注意,RANK
是训练过程的唯一 ID,而不是一个 pod。如果一个 pod 拥有多个 GPU,则可以运行多个训练过程。在这个示例中,因为我们每个 pod 运行一个训练过程,所以我们为每个 pod 分配一个不同的 RANK
值。
当我们在一个 pod 中运行多个训练过程时,我们需要为一个 pod 分配多个 RANK
值。例如,当我们在一个 pod 中运行两个进程时,这个 pod 需要两个 RANK
值,一个用于每个进程。
您可能注意到,此示例中创建的 Kubernetes pod 和服务是针对 PyTorch 分布式训练库进行定制的。实际上,该示例服务并不局限于 PyTorch。为了支持使用其他框架编写的训练代码,例如 TensorFlow 2,我们可以扩展 Kubernetes 作业跟踪器以支持 TensorFlow 分布式训练的设置。
例如,我们可以收集所有工作 pod 的 IP 或 DNS,并将它们放在一起,然后将它们广播回每个工作 pod。在广播期间,我们将工作组信息设置为每个 pod 中的TF_CONFIG
环境变量,以启动分布式训练组。TF_CONFIG
环境变量是 TensorFlow 分布式库的特殊要求。
4.3.4 更新和获取作业状态
创建训练 pod 后,Kubernetes 作业跟踪器将继续查询 pod 执行状态,并在其状态更改时将作业移动到其他作业列表中。例如,如果 pod 成功创建并开始运行,则跟踪器将作业从启动列表移动到运行列表。如果 pod 执行完成,则跟踪器将作业从运行列表移动到已完成作业列表。图 4.5 描绘了这个过程。
图 4.5 跟踪 Kubernetes 训练作业状态:第 1 步,获取运行列表中的作业;第 2 步,查询运行在 Kubernetes 集群中的每个作业的 pod 执行状态;第 3 步,如果 pod 执行完成(成功或失败),则将作业移动到已完成作业列表中。
当用户提交作业状态查询时,训练服务将在内存存储中的所有四个作业队列中搜索作业 ID 并返回作业对象。有趣的是,尽管有多个训练 pod,我们只需要检查主 pod 的状态来跟踪分布式训练的进度。这是因为,在同步数据并行训练中,所有工作节点在每个训练周期都必须相互同步,因此主 pod 可以代表其他工作节点。
查询和更新作业执行状态的代码与我们在第 3.3.5 节中看到的 Docker 作业跟踪器非常相似。唯一的区别是,我们查询 Kubernetes 集群而不是 Docker 引擎来获取训练状态。我们将代码留给您去探索;您可以在KubectlTracker
类的updateContainerStatus
方法中找到它。
4.3.5 将训练代码转换为分布式运行
我们对我们的意图分类训练代码(在上一章节,第 3.3.6 节中介绍)进行了两处更改,以支持分布式模式和单设备模式。
第一次更改:初始化训练组
我们使用WORLD_SIZE
环境变量来检查训练代码是否应在分布式训练中运行。如果 world size 等于 1,则我们使用与第 3.3.6 节中看到的相同的单设备训练代码。
但如果值大于 1,我们将初始化训练过程以加入分布式组。还请注意,从训练服务(Kubernetes 作业跟踪器)传递给每个 pod 的唯一RANK
值,这对于分布式组的初始化是必需的。在自注册到分布式组后,我们还将模型和数据采样器声明为分布式。参见以下代码的更改:
def should_distribute():
return dist.is_available() and config.WORLD_SIZE > 1
def is_distributed():
return dist.is_available() and dist.is_initialized()
if should_distribute():
# initialize the distributed process group,
# wait until all works are ready.
dist.init_process_group("gloo",
rank=config.RANK, world_size=config.WORLD_SIZE)
if is_distributed():
# wrap the model with DistributedDataParallel (DDP)
# package to enable data parallel training.
model = DDP(model)
if is_distributed():
# restricts data loading to a subset of the dataset
# exclusive to the current process
train_sampler = DistributedSampler(
dataset=split_train_, num_replicas=config.WORLD_SIZE,
rank=config.RANK)
第二个改变:只从主节点上传最终模型
在第二个改变中,我们只允许主节点(rank = 0)上传最终模型。这是为了防止每个工作节点多次上传相同的模型:
if config.RANK == 0: ❶
accu_test = evaluate(test_dataloader)
.. .. ..
# upload model to metadata store.
artifact = orca3_utils.create_artifact(
config.MODEL_BUCKET, config.MODEL_OBJECT_NAME)
.. .. ..
❶ Rank 0 是主节点。
4.3.6 改进
如果我们继续将这个示例服务推向生产就绪状态的路径,我们可以遵循第 4.2.2 节中的思路,努力改进容错性并减少网络带宽饱和度。我们还可以扩展 Kubernetes 作业跟踪器以支持 TensorFlow 和 Horovod 分布式训练。从训练服务的角度来看,它们并没有太大的区别,因为训练服务传递给训练代码的配置非常通用;这些信息对于所有框架都是必需的,但名称不同。只要训练服务和训练代码之间的协议清晰稳定,我们仍然可以将训练代码视为黑盒,在分布式环境中甚至可以这样处理。
4.4 不能在单个 GPU 上加载的大型模型训练
在研究领域,神经网络大小(由参数数量定义)正在迅速增长,我们不能忽视这一趋势。以 ImageNet 挑战为例,2014 年的获胜者(GoogleNet)有 400 万个参数;2017 年的获胜者(Squeeze-and-Excitation Networks)有 1.458 亿个参数;当前领先的方法有超过 10 亿个参数。
尽管我们的神经网络大小增长了近 300 倍,但 GPU 内存仅增加了 4 倍。将来我们会更频繁地遇到无法训练模型的情况,因为它无法加载到单个 GPU 上。
在本节中,我们将讨论训练大型模型的常见策略。与第 4.2 节中描述的数据并行策略不同,这里介绍的方法需要在训练代码上付出努力。
注意:虽然本节介绍的方法通常由数据科学家实现,但我们希望您仍然能够理解它们。了解这些训练技术背后的策略对于设计训练服务和训练代码之间的通信协议非常有帮助。它还为在训练服务中进行故障排除或微调训练性能提供了洞察力。为了简单起见,我们只会在概念级别上描述算法,并侧重于工程角度上的必要工作。
4.4.1 传统方法:节省内存
假设您的数据科学团队想要训练一个可以加载到您训练集群中最大 GPU 的模型;例如,他们想要在一个 10 GB 内存的 GPU 上训练一个 24 GB 的 BERT 模型。团队可以使用几种节省内存的技术来在这种情况下训练模型,包括梯度累积和内存交换。这项工作通常由数据科学家实现。作为平台开发人员,您只需了解这些选项。我们将简要描述它们,这样您就会知道何时建议使用它们中的每一种。
注意还有其他几种节省内存的方法,例如 OpenAI 的梯度检查点(github.com/cybertronai/gradient-checkpointing
)和 NVIDIA 的 vDNN(arxiv.org/abs/1602.08124
),但由于本书不涉及深度学习算法,我们将它们留给独立研究。
梯度累积
在深度学习训练中,数据集被分成批次。在每个训练步骤中,用于损失计算、梯度计算和模型参数更新,我们将整个批次的示例(训练数据)加载到内存中,并一次处理所有计算。
我们可以通过减小批量大小来减轻内存压力,例如将批次中的示例数量从 32 减少到 16。但是减小批量大小可能会导致模型收敛速度大大降低。这就是梯度累积可以帮助的地方。
梯度累积将批量示例分成可配置数量的小批量,然后在每个小批量之后计算损失和梯度。但是,它不会更新模型参数,而是等待并累积所有小批量的梯度。然后,最终,根据累积梯度更新模型参数。
让我们看一个示例,了解这如何加快流程。想象一下,由于 GPU 内存限制,我们无法使用批量大小为 32 进行训练。使用梯度累积,我们可以将每个批次分成四个小批次,每个小批次大小为 8。因为我们累积所有四个小批次的梯度,并且仅在所有四个小批次完成后更新模型,所以该过程几乎等同于使用批量大小为 32 进行训练。不同之处在于,我们一次只在 GPU 中计算 8 个示例,而不是 32 个,因此成本比使用批量为 32 的情况慢 4 倍。
内存交换(GPU 和 CPU)
内存交换方法非常简单:它在 CPU 和 GPU 之间来回复制激活。如果您不习惯深度学习术语,请将激活想象为神经网络每个节点的计算输出。其思想是仅在 GPU 中保留当前计算步骤所需的数据,并将计算结果交换到 CPU 内存以供将来使用。
在此基础上,一种名为 L2L(层到层)的新的中继式执行技术将仅在 GPU 上保留正在执行的层和中转缓冲区。整个模型和保存状态的优化器都存储在 CPU 空间中。L2L 可以大大提高 GPU 吞吐量,并允许我们在价格合理的设备上开发大型模型。如果您对此方法感兴趣,可以查阅普迪佩迪等人撰写的论文“使用新的执行算法在恒定内存中训练大型神经网络”(arxiv.org/abs/2002.05645
),该论文在 GitHub 上还有一个 PyTorch 实现。
梯度累积和内存交换都是在较小的 GPU 上训练大模型的有效方法。但是,像大多数事情一样,它们会降低训练速度。由于这个缺点,我们通常只在原型设计时使用它们。
为了获得可行的训练速度,我们真的需要在多个 GPU 上分布式地训练模型。因此,在下一节中,我们将介绍一种更接近生产的方法:管道并行。它可以以令人印象深刻的训练速度分布式训练大型模型。
4.4.2 管道模型并行
在第 4.2 节中,我们讨论了最常用的分布式训练方法:数据并行。这种方法在每个设备上保留整个模型的副本,并将数据划分为多个设备。然后它聚合梯度并在每个训练步骤中更新模型。整个数据并行的方法效果很好,只要整个模型可以加载到一个 GPU 上。然而,正如我们在本节中看到的那样,我们并不总是能够做到这一点。这就是管道并行的用处所在。在本节中,我们将学习管道并行,这是一种在多个 GPU 上分布式训练大型模型的训练方法。
要理解管道并行,让我们先简要了解模型并行。这个小插曲将使我们更容易转向管道并行。
模型并行
模型并行的思想是将神经网络分割成较小的子网并在不同的 GPU 上运行每个子网。图 4.6 说明了模型并行的方法。
图 4.6 将四层全连接深度学习网络分为四个子群组;每个群组有一层,每个子群组在一个 GPU 上运行。
图 4.6 展示了模型并行的过程。首先,它将神经网络(四层)转换为四个子神经网络(单层),然后为每个单层网络分配一个专用的 GPU。通过这样做,我们在四个 GPU 上分布式地运行模型。
模型并行的概念很简单,但实际实现可能会有些棘手;它取决于网络的架构。为了让您有一个概念,下面的代码片段是一个在两个 GPU 上运行网络的虚构的 PyTorch 代码片段。
列表 4.2 是在 PyTorch 中实现模型并行的示例代码
gpu1 = 1
gpu2 = 2
class a_large_model(nn.Module):
def __init__(self):
super().__init__()
# initialize the network as two subnetworks.
self.subnet1 = ...
self.subnet2 = ...
# put subnetwork 1 and 2 to two different GPUs
self.subnet1.cuda(gpu1)
self.subnet2.cuda(gpu2)
def forward(x):
# load data to GPU 1 and calculate output for
# subnet 1, GPU 2 is idle at the moment.
x = x.cuda(gpu1)
x = self.subnet1(x)
# move the output of subnet 1 to GPU 2 and calculate
# output for subnet 2\. GPU 1 is idle
x = x.cuda(gpu2)
x = self.sub_network2(x)
return x
如列表 4.2 所示,在__init__
函数中初始化了两个子网络并将其分配到两个 GPU 上,然后在forward
函数中将它们连接起来。由于深度学习网络结构的多样性,不存在一种通用方法(范式)来拆分网络。我们必须逐个实现模型并行。
模型并行的另一个问题是严重浪费 GPU 资源。由于训练组中的所有设备都有顺序依赖性,一次只能有一个设备工作,这会浪费大量的 GPU 时钟周期。图 4.7 显示了使用三个 GPU 进行模型并行训练时的 GPU 利用情况。
图 4.7 模型并行训练可能导致 GPU 利用率严重下降。在这种方法中,网络被分为三个子网并在三个 GPU 上运行。由于三个 GPU 之间的顺序依赖关系,每个 GPU 在训练时间的 66%空闲。
让我们通过这张图来看看为什么 GPU 使用率如此之低。在左边,图 4.7(a)中,我们看到了模型并行设计。我们将模型网络分成三个子网络,并让每个子网络在不同的 GPU 上运行。在每个训练迭代中,当运行正向传播时,我们首先计算子网 1,然后计算子网 2 和子网 3;当运行反向传播时,梯度更新则发生在相反的顺序中。
在图 4.7(b)中,右边,你可以看到在训练过程中三个 GPU 的资源利用情况。时间轴分为两部分:正向传播和反向传播。正向传播意味着模型推断的计算,从 GPU 1 到 GPU 2 和 GPU3,而反向传播意味着模型权重更新的反向传播,从 GPU 3 到 GPU 2 和 GPU 1。
如果你在时间条上垂直观察,无论是正向传播还是反向传播,你都只能看到一个 GPU 在工作。这是因为每个子网之间存在顺序依赖关系。例如,在正向传播中,子网 2 需要等待子网 1 的输出来完成自己的正向计算,因此在 GPU 1 完成计算之前,GPU 2 将在正向传播中空闲。
无论你添加多少个 GPU,一次只能有一个 GPU 工作,这是一种巨大的浪费。这时就轮到管道并行 ism 派上用场了。管道并行 ism 通过消除这种浪费并充分利用 GPU 来使模型训练更加高效。让我们看看它是如何工作的。
管道并行 ism
管道并行 ism 本质上是模型并行 ism 的改进版本。除了将网络划分到不同的 GPU 中,它还将每个训练示例批次分成小的微批次,并在层之间重叠这些微批次的计算。通过这样做,它使所有 GPU 大部分时间都保持忙碌,从而提高了 GPU 的利用率。
这种方法有两个主要的实现:PipeDream(微软)和 GPipe(谷歌)。我们在这里使用 GPipe 作为演示示例,因为它优化了每个训练步骤中的梯度更新,并且具有更好的训练吞吐量。你可以从“GPipe:使用微批量管道并行 ism 轻松扩展”的 Huang 等人的论文中找到有关 GPipe 的更多细节(arxiv.org/abs/1811.06965
)。让我们在图 4.8 中,以高层次来看一下 GPipe 是如何工作的。
图 4.8(a)显示了一个具有顺序层的示例神经网络被分区到四个加速器上。 F[k] 是第 k 个单元的组合前向计算函数。 Bk 是反向传播函数,它依赖于来自上一层的 B[k+1] 和 F[k]。 (b)naive 模型并行 ism 策略由于网络的顺序依赖关系而导致严重的利用不足。(c)流水线并行 ism 将输入 minibatch 划分为更小的微批次,使不同的加速器可以同时处理不同的微批次。 梯度在最后同步应用。 (来源:图 2,“GPipe:使用微批次管道并行 ism 轻松扩展”,Huang 等人,2019 年,arXiv:1811.06965)
图 4.8(a)描述了一个由四个子网络组成的神经网络; 每个子网络都加载在一个 GPU 上。 F 表示前向传递,B 表示后向传递,而 F[k] 和 B[k] 则在 GPUk 上运行。 训练顺序首先是前向传递,F[0] -> F[1] -> F[2] -> F[3],然后是后向传递,F[3] -> (B[3], F[2]) -> (B[2], F[2]) -> (B[1], F[1]) -> B[0]。
图 4.8(b)显示了 naive 模型并行 ism 的训练流程。 我们可以看到 GPU 严重未被利用; 在前向传递和后向传递中只有一个 GPU 被激活; 因此,每个 GPU 有 75% 的空闲时间。
图 4.8(c)显示了 GPipe 在训练操作序列中的改进。 GPipe 首先将每个训练示例批次划分为四个相等的微批次,并通过四个 GPU 进行管道处理。 图中的 F[(0,2)] 表示在 GPU 0 上使用 minibatch 2 进行前向传递计算。 在后向传递期间,基于用于前向传递的相同模型参数计算每个微批次的梯度。 关键在于它不会立即更新模型参数; 相反,它会累积每个微批次的所有梯度。 在每个训练批次结束时,我们使用来自所有四个微批次的累积梯度来更新所有四个 GPU 上的模型参数。
通过比较图 4.8(b)和(c),我们可以看到 GPU 利用率大大提高; 现在每个 GPU 有 47% 的空闲时间。 让我们看一个使用 PyTorch GPipe 实现来在两个 GPU 上训练一个 transformer 模型的代码示例(请参见下面的清单)。 为了清楚地演示这个想法,我们只保留与管道相关的代码,并将它们分成四部分。 您可以查看由 Pritam Damania 撰写的教程“使用流水线并行 ism 训练 transformer 模型的 PyTorch”来获取完整的代码(mng.bz/5mD8
)。
清单 4.3 使用流水线并行 ism 训练 transformer 模型
## Part One: initialize remote communication
# for multiple machines
rpc.init_rpc(
name="worker",
# set rank number to this node, rank is the global
# unique id of a node, 0 is the master,
# other ranks are observers
rank=0,
# set the number of workers in the group
world_size=1,
.. .. ..
)
.. .. ..
## Part Two: split model to 2 subnetworks, load
# to different GPUs and initialize the pipeline.
num_gpus = 2
partition_len = ((nlayers - 1) // num_gpus) + 1
# Add all the necessary transformer blocks.
for i in range(nlayers):
transformer_block = TransformerEncoderLayer(emsize,
nhead, nhid, dropout)
.. .. ..
# Load first half encoder layers to GPU 0 and second hard encoder layers to GPU 1.
device = i // (partition_len)
tmp_list.append(transformer_block.to(device))
# Load decoder to GPU 1.
tmp_list.append(Decoder(ntokens, emsize).cuda(num_gpus - 1))
module_list.append(nn.Sequential(*tmp_list))
## Part Three: Build up the pipeline.
chunks = 8 # Set micro-batches number to 8.
model = Pipe(torch.nn.Sequential(*module_list), chunks = chunks)
.. .. ..
## Part 4: Train with pipeline
def train():
model.train() # Turn on the train mode
.. .. ..
for batch, i in enumerate(range(0, nbatches, bptt)):
data, targets = get_batch(train_data, i)
optimizer.zero_grad()
# Compute pipeline output,by following the pipeline setup,
# the Pytorch framework will coordinate the network computation
# between GPU 0 and GPU 1.
# Since the Pipe is only within a single host and process the "RRef"
# returned by forward method is local to this node and can simply
# retrieved via "RRef.local_value()".
output = model(data).local_value()
# Compute the loss on GPU 1.
# Need to move targets to the device where the output of the
# pipeline resides.
loss = criterion(output.view(-1, ntokens), targets.cuda(1))
# Backprop and model parameters update are the same as single GPU training.
# The Pytorch framework hides all the details of micro-batches
# computation and model parameters update.
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()
.. .. ..
如我们从清单 4.3 中看到的,流水线并行代码比分布式数据并行 ism 复杂得多。 除了设置通信组之外,我们还需要考虑如何划分我们的模型网络并在工作进程间传输梯度和激活(对子网络的前向输出建模)。
4.4.3 软件工程师如何支持流水线并行 ism
你可能注意到我们在本节中讨论的所有方法都是用于编写训练代码的技术。因为数据科学家通常编写训练代码,所以你可能想知道作为软件开发人员,我们能做些什么来支持流水线并行训练。
首先,我们可以着手构建训练服务来自动化流水线训练的执行,并提高资源利用率(例如,始终保持 GPU 忙碌)。这种自动化包括分配工作资源,启用工作节点间的通信,以及将流水线训练代码和相应的初始化参数分发给每个工作节点(例如,工作节点 IP 地址,进程 ID,GPU ID 和工作节点组大小)。
第二,我们可以向数据科学家团队介绍新的分布式训练选项。有时候数据科学家团队对能够改善模型训练体验的新工程方法并不了解,所以在这里沟通非常重要。我们可以与团队成员合作,引导对流水线并行方法进行实验。
第三,我们可以着手改善模型训练的可用性。在 4.2.4 节中,我们讨论了分布式训练的脆弱性;它要求每个工作节点表现一致。如果一个工作节点失败,整个训练集也会失败,这是对时间和预算的巨大浪费。数据科学家会非常感激我们在训练过程监控、故障转移和故障恢复上所付出的努力。
数据并行还是流水线并行?
现在我们知道有两种主要的分布式训练策略:数据并行和流水线并行。你可能对这些概念有所了解,但可能仍然不确定何时使用它们。
我们建议始终从单个机器上进行模型训练。如果你有一个大型数据集并且训练时间很长,那么考虑分布式训练。我们总是更喜欢使用数据并行而不是流水线并行,仅仅因为数据并行的实现更简单,我们可以更快地得到结果。如果模型太大无法加载到一个 GPU 中,那么流水线并行是正确的选择。
摘要
-
分布式训练有两种思路:数据并行和模型并行。流水线并行是模型并行的改进版。
-
如果一个模型可以加载到一个 GPU 中,数据并行是实现分布式训练的主要方法;它易于使用并提供了很大的速度改进。
-
使用 Kubernetes 来管理计算集群可以大大减少计算资源管理的复杂性。
-
尽管每个训练框架(TensorFlow、PyTorch)提供了不同的配置和 API 来编写分布式训练代码,但它们的代码模式和执行流程非常相似。因此,训练服务可以采用统一的方法支持各种分布式训练代码。
-
在封装各种训练框架的设置配置后,即使在分布式训练环境中,训练服务仍然可以将训练代码视为黑盒处理。
-
要获取数据并行训练的进展/状态,您只需检查主工作器,因为所有工作器始终彼此同步。此外,为避免在训练工作完成后从所有工作器保存重复的模型,您可以设置训练代码,以确保仅在主工作器执行时保存模型和检查点文件。
-
Horovod 是一个优秀的分布式训练框架。它提供了一种统一的方法来运行在各种框架(如 PyTorch、TensorFlow、MXNet 和 PySpark)中编写的分布式训练代码。如果训练代码使用 Horovod 实现分布式训练,训练服务可以使用一种方法(Horovod 方法)来执行它,而不管它是用哪个训练框架编写的。
-
可用性、弹性和故障恢复是分布式训练的重要工程问题。
-
针对无法放入一个 GPU 的模型,有两种训练策略:节省内存的方法和模型并行的方法。
-
节省内存的方法每次只将模型的一部分或小批量数据加载到 GPU 上——例如,梯度累积和内存交换。这些方法易于实施,但会减慢模型的训练过程。
-
模型并行的方法将一个大型模型划分为一组子神经网络,并将它们分布到多个 GPU 上。这种方法的缺点是 GPU 利用率较低。为了克服这个问题,发明了流水线模型并行。
第五章:超参数优化服务
本章内容包括
-
超参数及其重要性
-
超参数优化(HPO)的两种常见方法
-
设计一个 HPO 服务
-
三个流行的 HPO 库:Hyperopt、Optuna 和 Ray Tune
在前两章中,我们看到了模型是如何训练的:一个训练服务管理着远程计算集群中的训练过程,并提供给定的模型算法。但模型算法和训练服务并不是模型训练的全部。还有一个组成部分我们尚未讨论过——超参数优化(HPO)。数据科学家经常忽视这样一个事实,即超参数选择可以显着影响模型训练结果,特别是当这些决策可以使用工程方法自动化时。
超参数是必须在模型训练过程开始之前设置其值的参数。学习率、批量大小和隐藏层数量都是超参数的示例。与模型参数的值(例如权重和偏置)不同,超参数在训练过程中不能被学习。
研究表明,超参数的选择值可以影响模型训练的质量以及训练算法的时间和内存要求。因此,必须调整超参数以使其对模型训练最优。如今,HPO 已经成为深度学习模型开发过程中的标准步骤。
作为深度学习组件之一,HPO 对软件工程师非常重要。这是因为 HPO 不需要对深度学习算法有深入的理解,所以工程师经常被分配到这项任务。大多数情况下,HPO 可以像黑盒子一样运行,训练代码不需要修改。此外,工程师有能力构建一个自动 HPO 机制,使 HPO 成为可能。由于要调整的超参数(学习率、epoch 数量、数据批量大小等)以及要尝试的值太多,手动调整每个超参数值是不现实的。软件工程师非常适合创建一个自动化系统,因为他们在微服务、分布式计算和资源管理方面有着丰富的经验。
在本章中,我们将重点介绍自动 HPO 的工程。我们首先介绍了与使用 HPO 工作所需的背景信息。我们深入了解超参数及其调整或优化过程。我们还将遇到一些流行的 HPO 算法,并比较了两种自动化 HPO 的常见方法:使用库和构建服务。
然后我们将开始设计。我们将看看如何设计一个 HPO 服务,包括创建 HPO 服务的五个设计原则,以及在此阶段特别重要的一个通用设计提案。最后,我们向您展示三个流行的开源 HPO 框架,如果您想在本地优化训练代码,这些框架将是完美的选择。
不同于之前的章节,本章我们不会构建一个全新的示例服务。相反,我们建议您使用开源的 Kubeflow Katib(见附录 C 中讨论)。Katib 是一个设计良好、可扩展且高度可移植的 HPO 服务,几乎可以用于任何 HPO 项目。因此,如果对您来说这是一个低成本的解决方案,我们就不需要再构建一个了。
本章应该为您提供 HPO 领域的全面视角,同时还为您提供了如何针对您的具体需求运行 HPO 的实用理解。无论您决定使用远程服务还是在本地机器上使用 Hyperopt、Optuna 或 Ray Tune 等库/框架来运行 HPO,我们都可以为您提供支持。
5.1 理解超参数
在我们学习如何调整超参数之前,让我们更清晰地了解一下超参数是什么以及它们为什么重要。
5.1.1 什么是超参数?
训练深度学习模型的过程使用两种类型的参数或数值:模型参数和超参数。模型参数是可训练的,也就是说,在模型训练过程中它们的值是学习到的,并且随着模型的迭代而改变。相比之下,超参数是静态的;这些配置在训练开始之前就已经被定义和设置好了。例如,我们可以在输入参数中将训练时期设置为 30,并将神经网络的激活函数设置为 ReLU(修正线性单元)来启动模型训练过程。
换句话说,任何影响模型训练性能但无法从数据中估计的模型训练配置都是超参数。一个模型训练算法中可能有数百个超参数,包括例如模型优化器的选择—ADAM(见“Adam: A Method for Stochastic Optimization,” by Diederik P. Kingma and Jimmy Ba; arxiv.org/abs/1412.6980
)或 RMSprop(见“A Look at Gradient Descent and RMSprop Optimizers,” by Rohith Gandhi; mng.bz/xdZX
)—神经网络中的层数、嵌入维度、小批量大小和学习率。
5.1.2 超参数为什么重要?
超参数的值选择对模型训练结果有巨大影响。通常手动设置,这些值控制着训练算法执行的行为,并确定模型训练的速度和模型的准确度。
要亲自看到这种效果,您可以通过在 TensorFlow playground(playground.tensorflow.org
)中运行模型训练来尝试不同的超参数值。在这个在线游乐场中,您可以设计自己的神经网络,并训练它以识别四种类型的图案。通过设置不同的超参数,比如学习率、正则化方法、激活函数、神经网络层数和神经元数量,您不仅会看到模型性能的变化,还会看到学习行为的变化,比如训练时间和学习曲线。要在这个游乐场中训练一个能够识别复杂数据模式(如螺旋形)的模型,我们需要非常小心地选择超参数。例如,尝试将隐藏层数量设置为 6,每层神经元数量设置为 5,激活函数设置为ReLU
,数据批量大小设置为 10,正则化方法设置为L1
。经过近 500 个 epochs 的训练,您会发现该模型可以对螺旋形图表进行准确的分类预测。
在研究领域,超参数选择对模型性能的影响早已有据可查。以自然语言处理嵌入训练为例。一篇论文,“Improving Distributional Similarity with Lessons Learned from Word Embeddings,”由 Levy 等人(aclanthology.org/Q15-1016.pdf
)撰写,揭示了词嵌入的许多性能增益都归因于某些系统设计选择以及 HPO(Hyperparameter Optimization,超参数优化)而不是嵌入算法本身。在 NLP 嵌入训练中,这些作者发现超参数的选择比训练算法的选择更具影响力!因为超参数选择对模型训练性能非常关键,所以超参数调整现在已经成为模型训练过程中的标准步骤。
5.2 理解超参数优化
现在您已经对超参数是什么以及它们为何对模型训练如此重要有了坚实的理解,让我们转向优化这些超参数的过程。在本节中,我们将为您介绍 HPO 的步骤。我们还将看看用于优化超参数的 HPO 算法,以及执行 HPO 的常见方法。
5.2.1 什么是 HPO?
HPO,或调整,是发现一组产生最佳模型的超参数的过程。这里的最佳意味着在给定数据集上最小化预定义损失函数的模型。在图 5.1 中,您可以看到 HPO 在模型训练过程中的通用工作流程的高级视图。
图 5.1 这个 HPO 工作流程的高级视图显示,该过程本质上是一个实验,旨在找到最佳的超参数值。
从图 5.1. 可以看出,HPO 工作流程可以被可视化为一个由四个步骤构成的循环。它向我们展示了 HPO 过程是一个重复的模型训练过程,只是每次神经网络都会使用不同的超参数集进行训练。在这个过程中将发现最优的超参数集。我们通常将每次模型训练的运行称为“试验”。整个 HPO 实验是一个试验循环,在此循环中我们运行一个试验接着运行另一个试验,直到满足结束条件。
注意为了公正评估,每个 HPO 试验都使用相同的数据集。
每次试验分为四个步骤,如图 5.1. 所示。第一步是使用一组超参数值训练神经网络。第二步是评价训练输出(模型)。
在第 3 步中,HPO 过程检查是否已满足结束条件,例如是否已用完试验预算,或者是否在此试验中产生的模型已达到我们的性能评估目标。如果试验结果满足结束条件,则试验循环中断,实验结束。产生最佳模型评估结果的超参数值被视为最优超参数。
如果未满足结束条件,则过程进入步骤 4:HPO 过程将产生一组新的超参数值,并通过触发模型训练运行来开始一个新的试验。每个试验中使用的超参数值可以通过手动或自动由 HPO 算法生成。让我们在接下来的两个部分中更详细地看看这两种方法和 HPO 算法。
手动 HPO
作为数据科学家,我们经常手动选择超参数值来运行图 5.1. 中的 HPO 过程。尽管,可以承认的是,手动选择最佳超参数值更像是即兴表演而不是科学。但是我们也在借鉴我们的经验以及从其中获得的直觉。我们通常会使用经验性的超参数值开始模型训练,例如在相关的已发表论文中使用的值,然后进行一些微小的调整并测试模型。经过几次试验后,我们手动比较模型性能并从这些试验中选择表现最佳的模型。图 5.2. 演示了这个工作流程。
图 5.2. 手动选择超参数值可能会很繁琐且耗时。
手动 HPO 的最大问题在于我们不知道我们的超参数值是否最佳,因为我们只是选择一些经验值并对其进行微调。为了获得最优值,我们需要尝试所有可能的超参数值,也就是搜索空间。在图 5.2 的示例中,我们想要优化两个超参数:学习速率和数据集批处理大小。在 HPO 过程中,目标是找到产生最佳模型的 batch_size
和 learning_rate
对。假设我们将 batch_size
的搜索空间定义为 {8, 16, 32, 64, 128, 256},并将 learning_rate
的另一个搜索空间定义为 {0.1, 0.01, 0.001, 0.5, 0.05, 0.005}。那么我们需要验证的超参数值的总数是 36(62)。
因为我们要手动运行 HPO,我们必须运行模型训练过程(HPO 试验)36 次,并记录每个试验中使用的模型评估结果和超参数值。完成所有 36 次试验并比较结果后,通常是模型准确率,我们找到了最佳的 batch_size
和 learning_rate
。
手动运行整个超参数搜索空间的 HPO 可能会耗时、容易出错且繁琐,正如你所见。此外,深度学习超参数通常具有复杂的配置空间,通常由连续、分类和条件超参数的组合以及高维度组成。目前,深度学习行业正在向自动 HPO 迈进,因为手动 HPO 简单不可行。
自动 HPO
自动 HPO 是利用计算能力和算法自动找到训练代码的最佳超参数的过程。这一想法是使用高效的搜索算法在没有人类干预的情况下发现最佳超参数。
我们还希望自动 HPO 以黑盒方式运行,因此它对其正在优化的训练代码无知,因此我们可以轻松地将现有的模型训练代码引入到 HPO 系统中。图 5.3 显示了自动化的 HPO 工作流程。
图 5.3 自动化的 HPO 工作流程
在第 1 步,数据科学家向自动 HPO 系统提交 HPO 请求,该系统以黑盒方式运行 HPO 过程(图 5.3)。他们将要优化的超参数及其值搜索空间输入黑盒(图 5.3 中的“自动 HPO”框)–例如,学习速率的搜索空间可能是 [0.005, 0.1],数据集批处理大小的搜索空间可能是 {8, 16, 32, 64, 128, 256}。数据科学家还需要配置训练执行,例如训练代码;评估方法;退出目标;以及试验预算,比如这次实验总共 24 次试验。
一旦用户提交了 HPO 请求,HPO 实验(步骤 2)就会开始。HPO 系统安排所有试验并管理其训练执行;它还运行 HPO 算法为每个试验生成超参数值(从搜索空间中挑选值)。当试验预算用完或达到训练目标时,系统会返回一组最优的超参数值(步骤 3)。
自动 HPO 依赖于两个关键组件:HPO 算法和试验训练执行管理。使用高效的 HPO 算法,我们可以使用更少的计算资源找到最优的超参数值。通过使用复杂的训练管理系统,数据科学家可以在整个 HPO 过程中无需手动操作。
注意:由于手动 HPO 的低效性,自动 HPO 是主流方法。为了简洁起见,在本章的其余部分中,我们将使用术语 HPO 来指代“自动超参数优化”。
5.2.2 流行的 HPO 算法
大多数 HPO 算法可以归类为三个桶:无模型优化、贝叶斯优化和多途径优化。
注意:因为本章的主要目标是教授 HPO 工程,所以这里讨论的 HPO 算法将保持在高级别。本节的目标是为您提供足够的 HPO 算法背景知识,以便能够构建或设置 HPO 系统。如果您想了解算法背后的数学推理,请查看 AutoML: Methods, Systems, Challenges 一书的第一章“Hyperparameter Optimization”,作者是 Matthias Feurer 和 Frank Hutter (mng.bz/AlGx
),以及 Bergstra 等人的论文“Algorithms for Hyper-Parameter Optimization” (mng.bz/Zo9A
)。
无模型优化方法
在无模型方法中,数据科学家不对训练代码做任何假设,并忽略 HPO 试验之间的相关性。网格搜索和随机搜索是最常用的方法。
在网格搜索中,用户为每个超参数指定了一组有限的值,然后从这些值的笛卡尔积中选择试验超参数。例如,我们可以首先指定学习率的值集(搜索空间)为{0.1, 0.005, 0.001},数据批量大小为{10, 40, 100},然后用这些集合的笛卡尔积(作为网格值)构建网格,例如 (0.1, 10),(0.1, 40),和 (0.1, 100)。构建网格后,我们可以使用网格值开始 HPO 试验。
当超参数数量变大或参数的搜索空间变大时,网格搜索会遇到困难,因为在这种情况下所需的评估数量会呈指数增长。网格搜索的另一个问题是其效率低下。因为网格搜索将每组超参数候选视为相等,所以它会在非最优配置空间浪费大量计算资源,而在最优空间上则没有足够的计算资源。
随机搜索通过在超参数配置空间中随机采样,直到搜索的某个预算用尽为止来工作。例如,我们可以将学习速率的搜索空间设置为[0.001, 0.1],数据批量大小设置为[10, 100],然后将搜索预算设置为 100,这意味着它将运行总共 100 次 HPO 试验。在每次试验中,会在 0.001 和 0.1 之间随机选择一个值作为学习速率,并在 10 和 100 之间随机选择一个值作为数据批量大小。
此方法比网格搜索有两个优点。首先,随机搜索可以评估每个超参数的更多值,这增加了找到最优超参数集的机会。其次,随机搜索具有更简单的并行化要求;因为所有评估工作者可以完全并行运行,它们无需彼此通信,并且失败的工作者不会在搜索空间中留下空缺。但是在网格搜索中,失败的工作者可以跳过分配给 HPO 工作者的试验超参数。
随机搜索的缺点是不确定性;不能保证在有限的计算预算内找到最优的超参数集。理论上,如果我们允许足够的资源,随机搜索可以在搜索中添加足够的随机点,因此它将如预期地找到最优超参数集。在实践中,随机搜索被用作基线。
图 5.4 网格搜索和随机搜索的比较,以最小化具有一个重要参数和一个不重要参数的函数。(来源:Matthias Feurer 和 Frank Hutter 的“超参数优化”的图 1.1。在AutoML: Methods, Systems, Challenges中,由 Frank Hutter,Lars Kotthoff 和 Joaquin Vanschoren 编辑; Springer, 2019. www.automl.org/wp-content/uploads/2019/05/AutoML_Book_Chapter1.pdf)
图 5.4 展示了网格搜索和随机搜索之间的比较。网格搜索中的试验超参数候选项(黑色点)是重要参数值(行中)和不重要值点(列中)的笛卡尔积。它们的分布可以看作是搜索空间中的一个网格(白色方形画布)。随机搜索算法从搜索空间中随机获取超参数候选项。当给定足够的搜索预算时,其搜索点更有可能接近最优位置。
基于模型的贝叶斯优化
贝叶斯优化是一种用于全局优化昂贵黑箱函数的最先进的优化框架。它广泛应用于各种问题设置,例如图像分类,语音识别和神经语言建模。
贝叶斯优化方法可以使用不同的采样器,例如高斯过程回归 (见“高斯过程回归的直观教程”,Jie Wang; arxiv.org/abs/2009.10862
) 和基于树结构的 Parzen 估计方法 (TPE),来计算搜索空间中的超参数候选。简单来说,贝叶斯优化方法使用统计方法根据过去试验中使用的值及其评估结果计算新的超参数值建议。
注意 为什么叫贝叶斯优化?贝叶斯分析 (www.britannica.com/science/Bayesian-analysis
) 是一种广泛使用的统计推断方法,以英国数学家托马斯·贝叶斯 (www.britannica.com/biography/Thomas-Bayes
) 命名,它允许您将有关总体参数的先验信息与样本中包含的信息的证据相结合,以指导统计推断过程。基于这种方法,乔纳斯·莫库斯 (Jonas Mockus) 在他在 1970 年代和 1980 年代的全局优化工作中引入了贝叶斯优化 (见“贝叶斯线性回归”,布鲁娜·温德瓦尔德; www.researchgate.net/publication/333917874_Bayesian_Linear_Regression
) 这个术语。
贝叶斯优化方法背后的概念是,如果算法能够从过去的试验中学习,那么寻找最佳超参数的过程将更加高效。在实践中,贝叶斯优化方法可以通过较少的评估运行(试验)找到最佳超参数集,并且比其他搜索方法更稳定。图 5.5 显示了随机搜索和贝叶斯方法之间的数据采样差异。
图 5.5 随机搜索 (a) 和贝叶斯方法 (b) 的数据采样器比较,使用 10 次试验
假设最佳超参数值在 (x,y) = (0.5, 1),我们试图使用随机搜索和贝叶斯搜索找到它。在图 5.5 (a) 中,我们看到数据在搜索空间中随机抽样,其中 x := [–1.0, 1.0],y := [1, 5]。在图 5.5 (b) 中,我们看到数据在区域 (x := [0.3, 0.7],y := [1,1.5]) 中密集抽样,最佳值位于该区域。这种比较表明,在给定的搜索空间中,贝叶斯搜索更有可能找到最佳超参数,并且在有限的执行预算下,选择的(抽样的)超参数值在搜索过程中的每次实验后越来越接近最佳值。
还有其他先进的超参数优化算法,例如 Hyperband(mng.bz/Rlwv
)、TPE(mng.bz/2a6a
)和协方差矩阵适应进化策略(CMA-ES;mng.bz/1M5q
)。尽管它们不完全遵循与贝叶斯-高斯过程方法相同的数学理论,但它们共享相同的超参数选择策略:通过考虑历史评估结果来计算下一个建议的值。
多信度优化
多信度方法提高了无模型和贝叶斯优化方法的效率。如今,在大型数据集上调整超参数可能需要几个小时甚至几天。为了加速超参数优化,开发了多信度方法。采用这种方法,我们使用实际损失函数的所谓低信度近似来最小化损失函数。因此,在超参数优化过程中我们可以跳过很多计算。
在机器学习的背景下,损失函数(www.datarobot.com/blog/introduction-to-loss-functions/
)是评估训练算法对数据集建模效果的一种方法。如果模型输出(预测)与期望结果相差很远,损失函数应该输出较高的数字;否则,应该输出较低的数字。损失函数是机器学习算法开发的关键组成部分;损失函数的设计直接影响模型准确性。
尽管这种近似方法在优化性能和运行时间之间引入了一种权衡,但在实践中,加速往往超过了近似误差。有关更多详细信息,请参阅 Matthias Feurer 和 Frank Hutter 的“超参数优化”(www.automl.org/wp-content/uploads/2019/05/AutoML_Book_Chapter1.pdf)。
贝叶斯式超参数优化算法为什么有效?
Michael McCourt 的博客文章“高斯过程背后的直觉”(sigopt.com/blog/intuition-behind-gaussian-processes/
)对为什么贝叶斯优化算法可以在不检查搜索空间中的每个可能值的情况下找到最佳超参数集提供了很好的解释。在某些情况下,我们观察到的实验是独立的,例如抛硬币 50 次;一个的知识并不意味着对其他的了解。但是,幸运的是,许多情况具有更有用的结构,从中以往的观察结果能够提供对未观察到的结果的见解。
在机器学习的背景下,我们假设历史实验(训练试验)结果与未来实验结果之间存在某种关系。更具体地说,我们相信存在一个数学模型来描述这种关系。虽然使用贝叶斯方法——例如,高斯过程——来建模这种关系是一个非常强的假设,但我们得到了强大的力量来做出可证明的最优预测。一个额外的好处是,我们现在有一种处理模型预测结果不确定性的方法。
注意 如果您有兴趣将贝叶斯优化应用于深度学习项目,Quan Nguyen 的书籍 贝叶斯优化实战(Manning, 2022; www.manning.com/books/bayesian-optimization-in-action
)是一个很好的资源。
哪种 HPO 算法效果最好?
没有单一的 HPO 算法最好。不同的优化算法可能适用于不同的调优任务,在不同的约束条件下。其中一些变量可能包括搜索空间的外观(例如,超参数类型、值范围)、试验预算的外观以及目标是什么(最终最优性或随时最优性能)。图 5.6 显示了来自 Optuna (optuna.org/
) HPO 框架的 HPO 算法选择指南。
图 5.6 来自 Optuna HPO 框架的 HPO 算法选择备忘单
在图 5.6 中,我们看到一个关于何时使用以下三种 HPO 算法的决策图:高斯过程、TPE 和 CMA-ES。由于 HPO 是一个快速发展的领域,新的高效算法随时可能被发布,因此像这样的算法选择备忘单将很快过时。例如,FLAML (github.com/microsoft/FLAML
) 是一个新开发的 Python HPO 库,它在 HPO 过程中检查超参数之间的相关性;它绝对值得一试。因此,请咨询您的数据科学团队以获取最新的 HPO 算法选择指南。
注意 HPO 算法不是 HPO 工程的主要关注点。HPO 算法背后的数学可能会让人望而生畏,但幸运的是,这不是工程师的重点。通常,确定要为某个特定训练任务使用哪种 HPO 算法是数据科学家的工作。作为工程师,我们的角色是构建一个灵活、可扩展的黑盒式 HPO 系统,以便数据科学家可以轻松地使用任意 HPO 算法运行其模型训练代码。
5.2.3 常见的自动 HPO 方法
幸运的是,今天已经存在许多成熟的框架和系统用于进行 HPO。根据使用情况,它们分为两种不同的类别:HPO 库方法和 HPO 服务方法。图 5.7 说明了这两种方法。现在让我们逐一讨论它们。
HPO 库方法
在图 5.7(a)中,库方法,我们看到数据科学家自己管理 HPO 过程,从编码到执行。他们使用 HPO 库(例如 Hyperopt——一个开源的 Python HPO 库)编写整个 HPO 流程,并将其与训练代码一起集成到一个训练应用程序中。接下来,数据科学家在本地计算机或直接访问的服务器上运行此应用程序。应用程序内的 HPO 库将执行我们在图 5.3 中看到的 HPO 工作流。
图 5.7 两种不同的 HPO 方法:库 vs 服务。(a)HPO 库可以在本地计算机或经过预配置的服务器组上运行 HPO 实验(训练);(b)HPO 服务可以以完全远程和自动化的方式运行 HPO 实验。
灵活性和敏捷性是库方法的最大优势;你可以选择任何你喜欢的 HPO 算法/库,将它们集成到你的训练代码中,立即开始 HPO 过程,因为一切(训练加上超参数计算)都发生在你的本地计算机上。一些 HPO 库——例如 Ray Tune(5.4.3 节)——也支持并行分布式执行,但不是完全自动化的。这需要设置一个具有特定软件的分布式计算组,允许跨计算机通信,并且需要在每台服务器上手动启动并行过程。
库方法面临的最大挑战是可扩展性、可重用性和稳定性。HPO 需要大量的计算资源来执行其试验,因此单个服务器通常无法执行 HPO。即使具有分布功能,它仍然无法扩展。想象一下,我们想要使用 20 台服务器进行需要 10,000 次试验的 HPO 任务;我们需要在 20 台服务器上手动设置 HPO 过程,并在每次训练或 HPO 代码更改时重新设置。此外,如果 20 个并行工作中的 1 个失败,整个 HPO 工作组都会停止。为了解决这些问题,引入了 HPO 服务方法。
HPO 服务方法
现在让我们更仔细地看看 HPO 服务方法;我们为清晰起见重复图 5.7,这里呈现为图 5.8。在图 5.8(b)中,服务方法,我们看到 HPO 发生在一个远程计算集群中,由一个服务——HPO 服务管理。数据科学家只向服务提供训练代码和选定的 HPO 算法配置,并启动 HPO 作业。该服务管理计算资源分配和 HPO 工作流程(图 5.3)的执行;它跟踪每个试验的结果(模型性能指标,例如准确性),并在所有试验完成时向数据科学家返回最终的最佳超参数。
图 5.8 两种不同的 HPO 方法:库 vs 服务
该服务方法提供了真正的黑盒体验。数据科学家无需担心管理自己的服务器、设置试验工作者,以及学习如何修改训练代码以适应不同的 HPO 算法。HPO 服务会处理所有这些任务。作为 HPO 服务的用户,我们只需将参数传递给服务,然后服务会自动运行 HPO 并在最后返回最优超参数。该服务还负责自动缩放和失败试验作业的故障恢复。由于这些优点,服务方法现在是深度学习生产环境中的主导 HPO 方法。由于您现在熟悉了 HPO 的概念和方法,让我们在接下来的两节中看看如何设计 HPO 服务以及如何使用 HPO 库。
注意 HPO 不是一次性工作。如果使用不同的数据集进行训练,即使模型架构没有改变,您也需要重新进行 HPO。如果数据集发生变化,最优模型权重集也会发生变化,因此您需要进行新的 HPO 搜索工作。
5.3 设计一个 HPO 服务
现在你已经对 HPO 库方法有了很好的理解,让我们来回顾一下 HPO 服务方法。在这一节中,我们将看看如何设计一个 HPO 服务,以支持对任意模型训练进行自动和黑盒方式的 HPO。
5.3.1 HPO 设计原则
在我们查看具体的设计方案之前,让我们先来了解一下构建 HPO 服务的五个设计原则。
原则 1:训练代码不可知
HPO 服务需要对训练代码和模型训练框架保持不可知。除了支持像 TensorFlow、PyTorch 和 MPI 这样的任意机器学习框架之外,我们希望该服务能够调整任何编程语言编写的训练代码的超参数。
原则 2:在支持不同 HPO 算法方面具有可扩展性和一致性
从第 5.2.2 节的 HPO 算法讨论中,我们知道超参数搜索算法是 HPO 过程的核心。超参数搜索的效率决定了 HPO 的性能。一个好的 HPO 算法可以在少量试验中找到大量超参数和任意搜索空间的最优超参数。
由于 HPO 算法研究是一个活跃的领域,每隔几个月就会发表一个新的有效算法。我们的 HPO 服务需要轻松集成这些新算法,并将它们作为算法选项暴露给客户(数据科学家)。此外,新添加的算法在用户体验方面应该与现有算法保持一致。
原则 3:可扩展性和容错性
除了 HPO 算法之外,HPO 服务的另一个重要责任是管理用于 HPO 的计算资源——具有各种超参数值的模型训练。从 HPO 实验的角度来看,我们希望在实验级别和试验级别进行分布式执行。更具体地说,我们不仅希望以分布式和并行的方式运行试验,还希望能够以分布式方式运行单个训练试验——例如,在一个试验中进行模型训练的分布式训练。从资源利用的角度来看,系统需要支持自动缩放,以使计算集群大小能够根据当前工作负载自动调整,从而不会出现资源的过度或不足利用。
容错性也是 HPO 试验执行管理的另一个重要方面。容错性很重要,因为一些 HPO 算法需要按顺序执行试验。例如,试验 2 必须在试验 1 之后进行,因为算法需要过去的超参数值和结果来推断下一个试验开始前的超参数。在这种情况下,当一个试验意外失败——例如,由于节点重新启动或网络问题——整个 HPO 过程都会失败。系统应自动从之前的故障中恢复。常见的方法是记录每个试验的最新状态,这样我们就可以从上次记录的检查点继续恢复。
原则 4:多租户性
HPO 过程本质上是一组模型训练执行。与模型训练类似,HPO 服务必须为各种用户或组提供资源隔离。这将确保不同的用户活动保持在其边界内。
原则 5:可移植性
如今,“云中立”概念变得非常流行。人们希望在不同的环境中运行他们的模型训练工作——亚马逊网络服务、谷歌云平台和 Azure——因此我们构建的 HPO 服务需要与基础架构解耦。在这里,使用 Kubernetes 运行 HPO 服务是一个不错的选择。
5.3.2 一般 HPO 服务设计
因为 HPO 工作流程(图 5.3)非常标准且变化不大,所以 HPO 服务系统设计(图 5.9)可以应用于大多数 HPO 场景。它由三个主要组件组成:API 接口、HPO 作业管理器和超参数(HP)建议生成器。(它们在图 5.9 中分别标记为 A、B 和 C。)
图 5.9 HPO 服务的一般系统设计
API 接口(组件 A)是用户提交 HPO 作业的入口点。要启动 HPO 实验,用户向接口提交 API 请求(步骤 1);请求提供模型训练代码,如 Docker 镜像;超参数及其搜索空间;以及 HPO 算法。
HP 建议制定者(组件 C)是不同 HPO 算法的包装器/适配器。它为用户运行每个不同的 HPO 算法提供了一个统一的接口,因此用户可以选择算法而不必担心执行细节。要添加新的 HPO 算法,必须在此建议制定者组件中注册它,以成为用户的算法选项。
HPO 作业管理器(组件 B)是 HPO 服务的核心组件;它管理客户请求的 HPO 实验。对于每个 HPO 请求,作业管理器启动一个 HPO 试验循环(步骤 2)。在循环中,它首先调用 HP 建议制定者来获得建议的超参数值集合(步骤 2.a),然后创建一个试验以使用这些超参数值运行模型训练(步骤 2.b 和 2.c)。
对于每个训练试验,HPO 作业管理器都会创建一个试验对象。该对象有两个职责:首先,它收集试验执行的输出,例如训练进度、模型指标、模型准确性和尝试的超参数;其次,它管理训练过程。它处理训练过程的启动、分布式训练设置和失败恢复。
HPO 服务的端到端执行流程
让我们按照图 5.9 显示的端到端用户工作流程来走一遍。为了方便起见,我们重复了图 5.9 并将其显示为图 5.10。
图 5.10 HPO 服务的一般系统设计
首先,用户向 API 接口提交 HPO 请求(步骤 1)。该请求定义了训练代码、超参数及其值搜索空间的列表、训练目标和一个 HPO 算法。然后,HPO 作业管理器为该请求启动 HPO 试验循环(步骤 2)。该循环启动一组试验来确定哪组超参数值最好。最后,当试算预算用尽或一次试验达到训练目标时,试验循环会中断,最优超参数会被返回(步骤 3)。
在试验循环中,作业管理器首先查询 HP 建议制定者以推荐超参数候选项(步骤 2.a)。制定者将运行所选的 HPO 算法来计算一组超参数值,并将其返回给作业管理器(步骤 2.b)。然后,作业管理器创建一个试验对象,以使用建议的超参数值启动模型训练过程(步骤 2.c)。试验对象还将监视训练过程,并继续向试验历史数据库报告训练指标,直到训练完成(步骤 2.d)。当作业管理器注意到当前试验已完成时,它将拉取试验历史记录(试验指标和用于过去试验的超参数值)并将其传递给 HP 建议制定者以获得新的 HP 候选项(步骤 2.e)。
因为 HPO 的使用案例非常标准和通用,并且已经有多个开源的 HPO 项目可以直接使用,我们认为学习如何使用它们比重新构建一个没有附加值的新系统更好。因此,在附录 C 中,我们将介绍一个功能强大且高度可移植的基于 Kubernetes 的 HPO 服务——Kubeflow Katib。
5.4 开源 HPO 库
对于一个小型数据科学家团队来说,HPO 服务可能会显得太过繁重,特别是如果他们所有的模型都是在他们自己管理的几台服务器上训练的话。在这种情况下,在本地机器或托管集群(小规模,1-10 台服务器)上使用 HPO 库来优化模型训练是一个更好的选择。
在这一部分,我们将介绍三个有用的 HPO 开源库:Optuna、Hyperopt 和 Ray Tune。它们都作为 HPO 库运行,而且易于学习和简单使用。因为 Optuna、Hyperopt 和 Ray Tune 都有明确的入门文档和合适的示例,我们将重点放在一般概述和功能介绍上,这样你就可以根据自己的情况决定使用哪一个。
在接下来关于不同的 HPO 库的讨论中,特别是在“如何使用”部分,你会经常看到术语 目标函数。什么是目标函数?图 5.11 展示了这个过程。
对于 HPO 算法(例如贝叶斯搜索)来说,为了生成一个超参数建议,使得下一个试验效果更好,它需要知道上一个 HPO 试验的表现如何。因此,HPO 算法要求我们定义一个函数来评分每个训练试验,并在随后的试验中继续最小化或最大化函数的返回值(分数)。我们将其命名为目标函数。
在图 5.11 中,我们看到一个目标函数接收超参数作为输入,并返回一个浮点值,或者分数。目标函数使用给定的超参数执行模型训练,并在训练完成时评估输出模型。
图 5.11 一个目标函数接收超参数作为输入,并返回一个分数。
5.4.1 Hyperopt
Hyperopt (hyperopt.github.io/hyperopt/#getting-started
) 是一个轻量级且易于使用的 Python 库,用于串行和并行 HPO。随机搜索、TPE 和自适应 TPE 是 Hyperopt 中实现的三种 HPO 算法。贝叶斯优化算法(基于高斯过程)和回归树已经设计用于适应,但在书写本书时尚未实现。
如何使用
假设你想知道哪种分类器对你的深度学习案例效果最好。我们可以使用 Hyperopt 在三个步骤中得到答案。
首先,我们创建一个目标函数,基本上是实际训练代码的包装函数,但从args
变量中读取超参数值。其次,我们为选定的超参数定义搜索空间。第三,我们选择一个 HPO 算法,该算法从搜索空间中选择超参数值,并将它们传递给目标函数以启动优化过程。列表 5.1 实现了这个场景。
在这个例子中,我们想确定哪个分类器能够产生最佳的模型准确性,因此我们选择在三个候选项中优化classifier_type
超参数:naive_bayes
、svm
和dtree
。你可能还注意到每个分类器都有自己的值搜索空间,比如对于svm
分类器,有hp.lognormal('svm_rbf_width',
0,
1)
。在fmin
函数中(在步骤 3 中),我们将 TPE 指定为 HPO 算法,最大试验数为 10,并将目标函数和搜索空间作为必需参数传入。
列表 5.1 Hyperopt 入门
# Step 1: define an objective function
def objective(args):
model = train(args) ❶
return evaluate(model) ❶
# Step 2 define search space for hyperparameters
space = hp.choice('classifier_type', [ ❷
{
'type': 'naive_bayes',
},
{
'type': 'svm',
'C': hp.lognormal('svm_C', 0, 1), ❸
'kernel': hp.choice('svm_kernel', [ ❸
{'ktype': 'linear'}, ❸
{'ktype': 'RBF', ❸
'width': hp.lognormal('svm_rbf_width', 0, 1)}, ❸
]),
},
{
'type': 'dtree',
'criterion': hp.choice('dtree_criterion',
['gini', 'entropy']),
'max_depth': hp.choice('dtree_max_depth',
[None, hp.qlognormal('dtree_max_depth_int', 3, 1, 1)]),
'min_samples_split': hp.qlognormal(
'dtree_min_samples_split', 2, 1, 1),
},
])
# Step 3 start the hpo process execution
best = fmin(objective, space, algo=tpe.suggest,
➥ max_evals=100) ❹
❶ 用传入的超参数训练模型并评估结果
❷ 声明了三个分类器候选项
❸ 为 SVM 分类器的参数定义了搜索空间
❹ fmin 函数在选择的算法上最小化目标函数的空间。
并行化
尽管 Hyperopt 是一个独立的库,但我们可以在一组机器的集群中并行运行它。基本思想是在不同的机器上运行 Hyperopt 工作者,并让它们与一个中央数据库进行通信以协调工作。Hyperopt 也可以使用 Spark 计算来并行运行 HPO。您可以查看以下两篇文章以获取更多详细信息:“On Using Hyperopt: Advanced Machine Learning” by Tanay Agrawal (mng.bz/PxwR
) 和 “Scaling Out Search with Apache Spark” (hyperopt.github.io/hyperopt/scaleout/spark/
)。
何时使用
Hyperopt 是小型或早期模型训练项目的一个不错的选择。首先,它易于使用。您可以在本地机器上或直接访问的服务器上运行 HPO 的三个步骤。其次,它对修改友好。由于采用了库方法,HPO 代码与训练代码放置在同一个代码项目中。因此,尝试不同的优化方案,比如选择不同的超参数进行调整,非常方便。
5.4.2 Optuna
与 Hyperopt 类似,Optuna 也是一个轻量级的 Python 库,旨在自动化超参数搜索。它支持大空间搜索和在不被看好的试验中进行早期修剪,以及在多个线程或进程上并行执行而无需修改代码。
在我们看来,Optuna 是 Hyperopt 的一个高级版本,其可视化能力要好得多。通过在图表中检查参数之间的相互作用,超参数搜索中的可视化能够为您提供大量见解,因此您可以轻松确定哪些参数比其他参数更有效。Optuna 的可视化效果美观且交互性强。
Optuna 在文档方面也优于 Hyperopt。Optuna 的文档非常出色。除了详细的 API 文档和良好组织的教程外,它还具有维护良好的源代码。如果您查看其 GitHub 项目的问题部分,您将会发现一个非常活跃且不断增长的社区,还有许多出色的功能和 GitHub 拉取请求即将推出。
如何使用
图 5.2 展示了如何快速使用 Optuna 的三个步骤示例:步骤 1,定义目标函数;步骤 2,创建一个表示 HPO 过程的研究对象;步骤 3,启动具有最大试验配额的 HPO 过程。
与 Hyperopt 相比,Optuna 要求大部分 HPO 逻辑在目标函数中定义。通用的代码模式如下。首先,定义搜索空间,并通过trial.suggest_xxx
函数生成超参数值。接下来,使用采样的超参数值开始模型训练。然后运行评估方法来计算模型性能并返回目标值。在下面的示例中,评估分数由mean_squared_error
计算。您可以在github.com/optuna/optuna-examples
找到更多 Optuna 示例。
图 5.2 使用 Optuna 入门
# Step 1: define an objective function
def objective(trial):
regressor_name = trial.suggest_categorical( ❶
'classifier', ['SVR', 'RandomForest']) ❶
if regressor_name == 'SVR':
svr_c = trial.suggest_float( ❷
'svr_c', 1e-10, 1e10, log=True) ❷
regressor_obj = sklearn.svm.SVR(C=svr_c) ❷
else:
rf_max_depth = trial.suggest_int('rf_max_depth', 2, 32) ❸
regressor_obj = sklearn.ensemble
.RandomForestRegressor(max_depth=rf_max_depth)
X_train, X_val, y_train, y_val = \
sklearn.model_selection.train_test_split(X, y, random_state=0)
regressor_obj.fit(X_train, y_train) ❹
y_pred = regressor_obj.predict(X_val)
error = sklearn.metrics
.mean_squared_error(y_val, y_pred) ❺
return error ❺
# Step 2: Set up HPO by creating a new study.
study = optuna.create_study()
# Step 3: Invoke HPO process
study.optimize(objective, n_trials=100)
❶ 设置分类器候选项
❷ 调用 suggest_XXX 方法生成超参数
❸ 在 2 和 32 范围内选择 max_depth
❹ 使用 Optuna 回归器运行模型训练
❺ 将均方误差设置为目标值,并链接到试验对象
并行化
我们可以使用 Optuna 在一台机器或一组机器上运行分布式 HPO。分布式执行设置非常简单,可以分为三个步骤:首先,启动一个关系型数据库服务器,例如 MySQL;其次,创建一个带有存储参数的研究;第三,将研究分享给多个节点和进程。与 Hyperopt 相比,Optuna 的分布式执行设置更简单,可以从单台机器扩展到多台机器而无需修改代码。
何时使用
Optuna 可以被视为 Hyperopt 的继任者;它具有更好的文档、可视化和并行执行。对于任何可以在一个或多个机器上运行的深度学习模型训练项目,您都可以使用 Optuna 来找到最佳的超参数。
Optuna 在支持大型数据科学团队或多个 HPO 项目时会受到限制,因为它需要管理一个中央机器集群来提供计算资源。但是 Optuna 的并行/分布式执行是手动的;人们需要将代码分发到每个服务器并一次在一个服务器上执行它,手动操作。为了以自动和编程方式管理分布式计算作业,我们可以使用 Kubeflow Katib(附录 C)或 Ray Tune。
5.4.3 Ray Tune
Ray (docs.ray.io/en/latest/index.html
) 提供了一个简单、通用的构建分布式应用程序的 API。Ray Tune (docs.ray.io/en/latest/tune/index.html
) 是建立在 Ray 之上的 Python 库,用于任何规模的 HPO。
Ray Tune 库支持几乎所有机器学习框架,包括 PyTorch、XGBoost、MXNet 和 Keras。它还支持最先进的 HPO 算法,如基于种群的训练(PBT)、BayesOptSearch 和 HyperBand/ASHA。此外,Tune 提供了一种机制,用于集成来自其他 HPO 库的 HPO 算法,如 Hyperopt 集成。
使用 Ray 作为其分布式执行支持,我们可以用几行代码启动一个多节点的 HPO 实验。Ray 将负责代码分发、分布式计算管理和容错。
如何使用
使用 Ray Tune 执行 HPO 任务非常简单。首先,定义一个目标函数。在函数中,从config
变量读取超参数值,开始模型训练,并返回评估分数。其次,定义超参数及其值搜索空间。第三,通过将目标函数和搜索空间链接起来,启动 HPO 执行。图 5.3 实现了上述三个步骤。
图 5.3 使用 Ray Tune 入门
# Step 1: define objective_function
def objective_function(config):
model = ConvNet() ❶
model.to(device)
optimizer = optim.SGD( ❷
model.parameters(), lr=config["lr"], ❷
momentum=config["momentum"]) ❷
for i in range(10):
train(model, optimizer, train_loader) ❸
acc = test(model, test_loader)
tune.report(mean_accuracy=acc) ❹
# Step 2: define search space for each hyperparameter
search_space = {
"lr": tune.sample_from(lambda spec:
10**(-10 * np.random.rand())),
"momentum": tune.uniform(0.1, 0.9) ❺
}
# Uncomment this to enable distributed execution
# `ray.init(address="auto")`
# Step 3: start the HPO execution
analysis = tune.run(
objective_function,
num_samples=20,
scheduler=ASHAScheduler(metric="mean_accuracy", mode="max"),
config=search_space)
# check HPO progress and result
# obtain a trial dataframe from all run trials
# of this `tune.run` call.
dfs = analysis.trial_dataframes
❶ ConvNet 是一个自定义的神经网络。
❷ 从输入配置中读取超参数值
❸ 开始模型训练
❹ 将评估结果(准确率)发送回 Tune
❺ 从 0.1 到 0.9 均匀地采样一个浮点值作为“动量”
在第 3 步中,你可能注意到一个调度器对象,ASHAScheduler
,被传递给train.run
函数。ASHA (mng.bz/JlwZ
) 是一个用于原则性提前停止的可扩展算法(参见“Massively Parallel Hyperparameter Optimization,” by Liam Li; mng.bz/wPZ5
)。在高层次上,ASHA 终止了不太有前途的试验,并为更有前途的试验分配时间和资源。通过适当调整参数num_samples
,搜索可以更加高效,并支持更大的搜索空间。
并行化
相比于 Optuna,Ray Tune 的最大优势就是分布式执行。Ray Tune 允许你透明地在多个 GPU 和多个节点上并行执行(请参阅Ray 文档)。Tune 甚至具备无缝的容错和云支持。与 Optuna 和 Hyperopt 不同,我们不需要手动设置分布式环境,并逐台执行工作脚本。Ray Tune 会自动处理这些步骤。图 5.12 展示了 Ray Tune 如何将 HPO Python 代码分发到一组机器上。
图 5.12 Ray Tune 在一组机器上运行分布式 HPO
首先,我们用命令"ray````up
tune-cluster.yaml"
建立了一个 Ray 集群;tune-cluster.yaml
是一个声明集群计算资源的集群配置。然后,我们运行以下命令将 HPO 代码从本地机器提交到集群的 head 节点:"ray
submit
tune-cluster.yaml
tune_ script.py
--start
--
--ray-address={server_address}"
。接下来,Ray 分配资源,将 HPO 代码复制到服务器,并启动分布式执行。更多详情,请参见“Tune Distributed Experiments” (mng.bz/71QQ
)。
除了分布式 HPO 执行,Ray Tune 还支持单次试验的分布式训练,自动检查点管理和 TensorBoard 日志记录。这些功能为 Ray Tune 增添了巨大的价值,因为它们具有高容错性和简单的故障排除能力。
什么时候使用
与其他 HPO 库相比,Ray Tune 是否是进行 HPO 的最佳选择?暂时是的。在撰写本书时,Ray 提供了底层训练框架(如 TensorFlow 和 PyTorch)与最新的 HPO 算法(例如贝叶斯搜索和 TPE),以及提前停止(ASHA)的集成。它允许我们以简单且可靠的方式分布式地运行 HPO 搜索。
对于大多数数据科学团队,不希望拥有 HPO 服务的情况下,Ray Tune 是建议的方法。它使用简单,并且几乎满足每个模型训练项目的 HPO 需求:丰富的文档,最新的 HPO 算法,高效且简单的分布式执行管理。
注意:我们推荐使用 Ray Tune 而不是其他 HPO 库,理由如下:(1) 使用简单;(2) 文档和示例丰富;(3) 其分布式执行是自动和程序化的;(4) Ray Tune 支持单次试验的分布式训练;(5) Ray Tune 具有调度程序功能(例如,ASHAScheduler
),可以通过提前终止不太被看好的试验大大降低计算成本。
Ray Tune 的局限性
当我们需要在一个共享的 HPO 系统中支持不同团队和不同的深度学习项目时,Ray Tune 和其他 HPO 库将受到限制。Ray Tune 缺乏计算隔离,这导致了两个大问题。
首先,不同训练代码的包版本可能会导致 Ray workers 之间的冲突。在 Ray Tune 中执行分布式 HPO 时,我们将 HPO 代码提交到 Ray 集群的 head 服务器,然后在集群 workers 中并行运行此代码。这意味着每个 Ray worker 服务器都需要安装每个它需要运行的训练代码的依赖库。想象一下,当您需要在一个 Ray 集群中运行 10 个不同的 HPO 任务时,工作机器需要为这 10 个不同的训练代码安装数百个包,并解决它们的版本冲突时,我们如何管理包安装和潜在的版本冲突。其次,Ray Tune 不强制执行用户隔离。在 Ray Tune 中为不同的数据科学团队建立虚拟边界以限制其计算资源使用是非常困难的。
5.4.4 后续步骤
当您遇到 HPO 库的上述问题时,是时候转向 HPO 服务了。我们强烈建议您在考虑构建自己的 HPO 之前阅读附录 C。它介绍了一个坚实的开源 HPO 服务,名为 Kubeflow Katib,这是一个设计良好的通用 HPO 服务。
摘要
-
超参数是用于控制学习过程的参数值。这种类型的参数在模型训练中不可学习;因此,我们需要调整它。
-
HPO 是一个发现一组超参数的过程,该组超参数产生了一个最优模型,该模型在给定数据集上最小化了预定义的损失函数。
-
自动 HPO 是使用计算资源和算法(HPO 算法)来自动找到训练代码的最佳超参数的过程。
-
自动 HPO 现在是模型训练的标准步骤。
-
大多数 HPO 算法可以归类为三种类型之一:无模型优化、贝叶斯优化或多态优化。
-
没有单一最佳的 HPO 算法。不同的优化算法可能适用于不同约束条件下的不同 HPO 任务。
-
HPO 可以在库中运行,也可以在远程服务中运行。库方法简单、灵活,适用于小团队和原型阶段的项目,而服务方法适用于大型组织和生产用例。
-
HPO 服务方法提供了一个完全自动的黑盒 HPO 体验,包括计算资源管理;因此,如果您正在为大型团队构建深度学习系统,我们建议采用服务方法。
-
构建 HPO 服务的五个设计原则是训练代码不可知、高可扩展性、高可扩展性和可靠性、HPO 执行和资源消耗隔离,以及高可移植性。
-
为了加速 HPO 实验,我们可以并行训练不同试验的训练执行,引入分布式训练,并及早停止不太有希望的试验。
-
我们鼓励您采用 Kubeflow Katib 作为您的 HPO 服务,而不是自己构建一个新的服务。
-
在三个常用的开源 HPO(超参数优化)库中——Optuna、Hyperopt 和 Ray Tune 中,到目前为止,Ray Tune 被证明是最好的。
第六章:模型服务设计
本章涵盖
-
定义模型服务
-
常见模型服务挑战和方法
-
设计不同用户场景下的模型服务系统
模型服务是使用用户输入数据执行模型的过程。在深度学习系统中的所有活动中,模型服务是最接近最终客户的活动。在完成了数据集准备、训练算法开发、超参数调整和测试结果生成模型的所有辛勤工作之后,这些模型由模型服务服务呈现给客户。
以语音翻译为例。在为语音翻译训练了一个序列到序列模型之后,团队准备向世界展示它。为了让人们远程使用这个模型,通常会将模型托管在 Web 服务中,并通过 Web API 公开。然后我们(客户)可以通过 Web API 发送我们的语音音频文件,并获得一个翻译后的语音音频文件。所有模型加载和执行都发生在 Web 服务后端。包括在这个用户工作流程中的一切——服务、模型文件和模型执行——都被称为模型服务。
构建模型服务应用程序是另一个特殊的深度学习领域,软件工程师特别适合这个领域。模型服务使用请求延迟、可伸缩性、可用性和可操作性——所有这些都是工程师内外熟知的领域。通过一些深度学习模型服务概念的介绍,有一些分布式计算经验的开发人员可以在构建模型服务元素方面发挥重要作用。
将模型在生产中提供服务可能很具有挑战性,因为模型是由各种框架和算法训练的,因此执行模型的方法和库各不相同。此外,模型服务领域使用的术语令人困惑,有太多不同的术语,如模型预测和模型推理,听起来不同但在服务上下文中意思相同。此外,有许多模型服务选项可供选择。一方面,我们有像 TensorFlow Serving、TorchServe 和 NVIDIA Triton 推理服务器等黑盒解决方案。另一方面,我们有像构建自己的预测服务或直接将模型嵌入应用程序中这样的定制方法。这些方法看起来都非常相似且功能强大,因此很难选择其中一个。因此,如果您对这个领域还不熟悉,您可能会很快迷失方向。
我们的目标是帮助你找到自己的方向。我们希望能赋予你设计和构建最适合你情况的模型服务解决方案的能力。为了实现这个目标,我们有很多内容需要介绍,包括模型服务的概念理解、服务设计考虑因素、具体示例和模型部署工作流程。为了避免让你阅读超长的一章内容,我们将这部分内容分成了两章:第六章重点关注概念、定义和设计,第七章将这些概念付诸实践,包括构建一个样本预测服务,介绍开源工具以及部署和监控模型生产。
在本章中,我们首先澄清术语,并为模型服务中使用的元素提供我们自己的定义。我们还描述了我们在模型服务领域面临的主要挑战。然后我们将转向设计方面,解释模型服务的三种常见策略,并设计一个适合不同用例的模型服务系统。
通过阅读本章,你不仅会对模型服务的工作原理有扎实的理解,还会了解到可以应对大多数模型服务用例的常见设计模式。随着概念和术语在你脑海中变得清晰,你应该可以自如地参与任何与模型服务相关的讨论,或者阅读关于这个主题的文章和论文。当然,本章也为你在下一章介绍的实际工作奠定了基础。
6.1 解释模型服务
在模型服务的工程中,术语是一个主要问题。例如,模型、模型架构、推理图、预测和推理等术语被人们使用时没有清晰地定义它们,因此它们可以具有相同的含义,也可以根据上下文(模型服务或模型训练)而指代不同的概念。当我们与数据科学家合作构建模型服务解决方案时,模型服务术语的混淆会导致很多交流不畅。在本节中,我们将从工程角度解释模型服务的核心概念,并对常用术语进行解释,以帮助你避免陷入术语陷阱。
6.1.1 什么是机器学习模型?
在学术界对机器学习模型有多种定义,从对数据集学习的精简表达到基于以前未见过的信息识别特定模式或做出决策的数学表达。然而,作为模型服务开发人员,我们可以简单地将模型理解为在训练过程中产生的一组文件的集合。
模型的概念很简单,但很多人误解模型只是静态文件。虽然模型被保存为文件,但它们并不是静态的,它们实质上是可执行的程序。
让我们分解这个语句并确定其含义。一个模型由机器学习算法、模型数据和模型执行器组成。模型执行器是机器学习算法的封装代码;它接收用户输入并运行算法来计算和返回预测结果。机器学习算法是指模型训练中使用的算法,有时也称为模型架构。再以语音翻译为例,如果翻译模型是由序列到序列网络作为其训练算法,则模型中的机器学习算法就是相同的序列到序列网络。模型数据是运行机器学习算法所需的数据,例如神经网络的学习参数(权重和偏差)、嵌入和标签类别等。图 6.1 展示了一个通用的模型结构。
图 6.1 一个模型由机器学习算法、模型执行器和模型数据组成。
注意 在本章中,我们经常简称机器学习算法为模型算法。
本节中最重要的要点是,模型训练执行的输出,也就是模型,并不仅仅只是一组静态数据。相反,深度学习模型是可执行程序,包括机器学习算法及其依赖的数据,因此模型可以根据运行时的输入数据进行预测。
注意 模型不仅仅包括权重和偏差。有时数据科学家将神经网络的训练参数(权重和偏差)保存到一个文件中,并命名为“模型文件”。这会让人们误以为模型只是一个只包含权重和偏差的数据文件。权重和偏差是模型的数据,但我们还需要算法和封装代码来运行预测。
6.1.2 模型预测和推断
学术界可能认为模型推断和预测是两个不同的概念。模型推断可以指学习数据是如何生成的、理解其原因和影响,而模型预测则可能指对未来事件的预测。
一个样本模型预测的场景可能包括使用销售记录来训练一个模型,以预测哪些个体可能会对下一次营销活动做出回应。而一个样本模型推断的场景将包括使用销售记录来训练一个模型,从产品价格和客户收入的角度理解销售效果。对于模型推断来说,之前未见过的数据上的预测准确性并不是非常重要,因为主要关注的是学习数据生成过程。模型训练的目的是拟合整个数据集。
从工程的角度来看,模型预测和模型推断意味着相同。虽然模型可以被建立和用于不同的目的,但是在模型服务的上下文中,模型预测和模型推断指的是同样的行为:使用给定的数据点执行模型以获得一组输出值。图 6.2 展示了预测模型和推断模型的模型服务工作流程;正如你所见,它们之间没有区别。
图 6.2 模型预测和模型推断在模型服务工程中是相同的。
为了简化本章中插图中的文本,从图 6.2 开始,我们使用 模型 一词来表示模型数据、模型执行者和机器学习 (模型) 算法。这不仅是为了保持文本简洁,也强调了机器学习模型是可执行程序。
6.1.3 什么是模型服务?
模型服务 意味着简单地使用输入数据执行模型进行预测,这包括获取预期的模型、设置模型的执行环境、使用给定的数据点执行模型进行预测,并返回预测结果。模型服务最常用的方法是在 Web 服务中托管模型,并通过 Web API 公开模型的预测功能。
假设我们构建了一个目标检测模型,用于检测海岸图片中的鲨鱼;我们可以构建一个网络服务来托管这个模型,并公开一个鲨鱼检测的 Web API。然后,世界上任何海滨酒店都可以使用这个 Web API 来检测他们自己海岸图片中的鲨鱼。在传统上,我们称模型服务的网络服务为预测服务。
预测服务中的典型模型预测工作流程有四个步骤:接收用户请求;从工件存储加载模型到内存或 GPU;执行模型的算法;最后返回预测结果。图 6.3 展示了这个工作流程。
图 6.3 预测服务中的典型模型预测工作流程
除了四步预测工作流程之外,图 6.3 还提到了模型服务的三个主要组件:预测服务 (A)、模型工件存储 (B) 和预测 Web API ©。模型工件存储 (组件 B) 包含模型训练生成的所有模型。Web API (组件 C) 接收预测请求。预测服务 (组件 A) 响应预测请求,从工件存储加载模型,运行模型,并返回预测结果。
虽然预测工作流程的四个步骤通常适用于所有类型的模型,但步骤的实际实现取决于业务需求、模型训练算法和模型训练框架。我们将在第 6.3 节讨论预测服务的设计选项,并在第七章中介绍两个示例预测服务。
模型服务以特殊模式运行机器学习算法。
模型训练和模型服务使用相同的机器学习算法,但是有两种不同的模式:学习模式和评估模式。
在学习模式中,我们以开环的方式运行算法,这意味着在每个训练迭代中,我们首先对神经网络(算法)运行一个输入数据样本来计算预测结果。根据预测结果与预期结果之间的差异,网络的参数(权重和偏差)会被更新以更接近数据集。
在评估模式中,神经网络(算法)在闭环中运行,这意味着网络的参数不会被更新。神经网络仅用于获取预测结果。因此从代码实现的角度来看,模型服务本质上是以评估模式运行机器学习算法(神经网络)。
6.1.4 模型服务的挑战
构建一个成本效益高的网络服务以服务模型比在我们的笔记本电脑上本地运行模型要复杂得多。以下是为网络服务提供模型所面临的六个常见挑战。
模型预测 API 根据模型算法而异。不同的深度学习算法(如循环神经网络和卷积神经网络 [CNN])需要不同的输入数据格式,其输出格式也可能不同。在设计 Web 预测 API 时,设计一个满足每种模型算法输入数据要求的统一 Web API 是非常具有挑战性的。
模型执行环境因训练框架而异。模型可以在不同的框架中进行训练,例如 TensorFlow 和 PyTorch。而每个训练框架都有其特殊的设置和配置来执行其模型。预测服务应该在其后端封装模型执行环境的设置,这样客户就可以专注于使用模型预测 API,而不是该模型所训练的框架。
有太多的模型服务工具、库和系统可供选择。如果我们决定使用现有的开源模型服务方法,那么立即出现的问题就是我们应该选择哪种方法。有 20 多种不同的选择,比如 TorchServe、TensorFlow Serving、NVIDIA Triton Inference Server、Seldon Core 和 KFServing。我们如何知道哪种方法最适合我们的情况?
没有通用的、最具成本效益的模型服务设计;我们需要量身定制适合我们自己用例的模型服务方法。与模型训练和超参数调整服务不同,它们都有一种适用于所有情况的方法——预测服务的设计严重依赖于具体的用户场景。例如,设计一个仅支持一个模型的预测服务,比如花卉识别模型,与设计一个支持 10 种不同类型模型的预测服务,比如 PDF 扫描、文本意图分类和图像分类,是完全不同的。
在保持资源饱和度的同时减少模型预测延迟。从成本效益的角度来看,我们希望我们的计算资源完全饱和于模型预测工作负载。此外,我们希望为客户提供实时的模型预测体验,因此我们不希望由于严格的基础设施预算而导致预测延迟下降。为了实现这一目标,我们需要创新地减少预测工作流的每个步骤的时间成本,比如更快地加载模型或在提供服务之前预热模型。
模型部署和部署后模型监控是我们在第一天就应该考虑的事情。模型部署——将模型从训练推进到生产——对于成功的模型开发至关重要。我们希望快速将模型推进到生产环境,并且我们希望在生产环境中有多个模型版本,这样我们可以快速评估不同的训练算法并选择最佳模型。部署后的模型监控可以帮助检测模型性能退化;这是欺诈检测和贷款批准等模型的关键保护机制。
好消息是,这些六个挑战都是工程问题,所以你能够处理它们!我们将在这里和下一章讨论如何解决它们。
6.1.5 模型服务术语
随着我们继续阅读本章,我们希望提醒你模型服务术语。许多术语在学术界有不同的定义,但在实践中讨论模型服务时是可以互换的。以下定义应该帮助你和你的同事在提到它们时避免混淆。
-
模型服务、模型评分、模型推断和模型预测在深度学习的上下文中是可以互换的术语。它们都指的是使用给定数据点执行模型。在本书中,我们将使用模型服务。
-
预测服务、评分服务、推断服务和模型服务是可以互换的;它们指的是允许远程执行模型的网络服务。在本书中,我们使用预测服务。
-
在模型服务的上下文中,预测和推断是可以互换的;它们是与运行模型算法相关的入口函数。在本书中,我们使用预测。
-
预测请求、评分请求 和 推断请求 是可以互换的;它们指的是执行模型以进行预测的 Web API 请求。在本书中,我们使用 预测请求。
-
机器学习算法、训练算法 和 模型算法 是可以互换的,正如我们在第 6.1.3 节中所述;在模型训练和服务中运行的算法是相同的机器学习算法(相同的神经网络),但处于不同的执行模式。
-
模型部署 和 模型发布 是可以互换的;它们指的是将经过训练的模型(文件)部署/复制到业务运行的生产环境中,以便客户可以从这个新模型中受益。通常,这指的是将模型文件加载到预测服务中。
6.2 常见的模型服务策略
在我们审查第 6.3 节中的具体模型服务用例和预测服务设计之前,让我们先了解三种常见的模型服务策略:直接模型嵌入、模型服务和模型服务器。无论你的具体用例需要做什么,通常可以采用以下三种方法之一来构建你的预测服务。
6.2.1 直接模型嵌入
直接模型嵌入意味着在用户应用程序的进程内加载模型并运行模型预测。例如,一个花卉识别的手机应用可以直接在其本地进程中加载图像分类模型,并从给定的照片中预测植物身份。整个模型加载和服务都发生在本地模型应用程序内(在手机上),而不需要与其他进程或远程服务器进行通信。
大多数用户应用程序,如手机应用程序,都是用 Go、Java 和 C# 等强类型语言编写的,但大多数深度学习建模代码是用 Python 编写的。因此,将模型代码嵌入应用程序代码是很困难的,即使你这样做了,这个过程也可能需要一段时间。为了在非 Python 进程中促进模型预测,PyTorch 和 TensorFlow 等深度学习框架提供了 C++ 库。此外,TensorFlow 还提供了 Java (github.com/tensorflow/java
) 和 JavaScript (github.com/tensorflow/tfjs
) 库,用于直接从 Java 或 JavaScript 应用程序加载和执行 TensorFlow 模型。
直接嵌入的另一个缺点是资源消耗。如果模型在客户端设备上运行,没有高端设备的用户可能会有不好的体验。运行大型深度学习模型需要大量的计算,这可能导致应用程序变慢。
最后,直接嵌入涉及将模型服务代码与应用程序业务逻辑混合在一起,这对向后兼容性构成了挑战。因此,因为它很少被使用,我们只简要描述它。
6.2.2 模型服务
模型服务 指的是在服务器端运行模型服务。对于每个模型、每个模型的版本或每种类型的模型,我们都为其构建一个专用的 Web 服务。这个 Web 服务通过 HTTP 或 gRPC 接口公开模型预测 API。
模型服务管理模型服务的全部生命周期,包括从模型制品存储库中获取模型文件、加载模型、为客户请求执行模型算法以及卸载模型以回收服务器资源。以文档分类用例为例,为了自动按照内容对图像和 PDF 中的文档进行分类,我们可以训练一个用于光学字符识别(OCR)的 CNN 模型来提取文档图像或 PDF 中的文本。为了在模型服务方法中为这个模型提供服务,我们为这个 CNN 模型专门构建一个 Web 服务,并且 Web API 仅设计用于这个 CNN 模型的预测函数。有时我们为每个主要模型版本更新构建一个专用的 Web 服务。
模型服务的常见模式是将模型执行逻辑构建到 Docker 镜像中,并使用 gRPC 或 HTTP 接口公开模型的预测函数。对于服务设置,我们可以托管多个服务实例,并使用负载均衡器将客户的预测请求分发到这些实例。
模型服务方法的最大优势是简单性。我们可以很容易地将模型的训练容器转换为模型服务容器,因为本质上,模型预测执行涉及运行经过训练的模型神经网络。模型训练代码可以通过添加 HTTP 或 gRPC 接口并设置神经网络为评估模式快速转换为预测 Web 服务。我们将在第 6.3.1 和 6.3.2 节中看到模型服务的设计和用例,并在第七章中看到一个具体的代码示例。
因为模型服务针对模型算法具体化,所以我们需要为不同的模型类型或版本构建单独的服务。如果您有多个不同的模型需要提供服务,这种一模型一服务的方法可能会产生许多服务,并且维护这些服务的工作——如打补丁、部署和监控——可能会很辛苦。如果您面临这种情况,模型服务器方法是正确的选择。
6.2.3 模型服务器
模型服务器方法旨在以黑盒方式处理多种类型的模型。无论模型算法和模型版本如何,模型服务器都可以使用统一的 Web 预测 API 操作这些模型。模型服务器是下一阶段;我们不再需要为新型模型或模型新版本进行代码更改或部署新服务。这从模型服务方法中节省了许多重复的开发和维护工作。
然而,模型服务器方法比模型服务方法更加复杂,实现和管理起来更加困难。在一个服务和一个统一的 API 中处理各种类型模型的模型服务是复杂的。模型算法和模型数据是不同的;它们的预测函数也是不同的。例如,图像分类模型可以用 CNN 网络训练,而文本分类模型可以用长短期记忆(LSTM)网络训练。它们的输入数据不同(文本 vs 图像),它们的算法也不同(CNN vs LSTM)。它们的模型数据也不同;文本分类模型需要嵌入文件来编码输入文本,而 CNN 模型不需要嵌入文件。这些差异给找到一个低维护、低成本和统一的服务方法带来了许多挑战。
虽然构建模型服务器方法很困难,但绝对可行。许多开源模型服务库和服务,如 TensorFlow Serving、TorchServe 和 NVIDIA Triton 推理服务器,提供了模型服务器解决方案。我们只需要构建定制的集成逻辑,将这些工具整合到我们现有系统中以解决业务需求,例如将 TorchServe 集成到我们的模型存储、监控和警报系统中。
从模型部署的角度来看,模型服务器采用黑盒方法。只要我们按照模型服务器的标准保存模型文件,当我们通过其管理 API 将模型上传到模型服务器时,模型预测应该正常工作。模型服务实现和维护的复杂性可以大大降低。我们将在第 6.3.3 节看到模型服务器的设计和用例,并在第七章看到使用 TorchServe 的代码示例。
注意我们是否总是应该考虑模型服务器方法?并不总是。如果我们不考虑服务开发成本和维护成本,模型服务器方法是最强大的,因为它设计用来覆盖所有类型的模型。但如果我们关心模型服务的成本效益——而我们应该关心!——那么理想的方法取决于用例。在下一节中,我们将讨论常见的模型服务用例和应用设计。
6.3 设计预测服务
在软件系统设计中一个常见的错误是试图构建一个无所不能的系统,而不考虑具体的用户场景。过度设计会将我们的注意力从即时的客户需求转移到未来可能有用的功能上。结果,系统要么需要花费不必要的时间来构建,要么难以使用。这对于模型服务尤其如此。
深度学习是一项昂贵的业务,无论是人力资源还是计算资源。我们应该只构建必需品,尽快将模型投入生产,并尽量减少操作成本。为此,我们需要从用户场景开始。
在本节中,我们将介绍三种典型的模型服务场景,从简单到复杂。对于每个用例,我们解释场景并说明一个适合的高级设计。通过按顺序阅读以下三个小节,您将看到当用例变得越来越复杂时,预测服务的设计如何演变。
注意 预测服务设计的目标不是构建适用于各种模型的强大系统,而是以成本效益的方式构建适合环境的系统。
6.3.1 单模型应用
想象一下构建一个可以在两张图片之间交换人脸的移动应用程序。消费者期望应用程序 UI 能够上传照片,选择源图片和目标图片,并执行一个 deepfake 模型(arxiv.org/abs/1909.11573
)来交换所选图片之间的人脸。对于只需要与一个模型一起工作的应用程序,服务方法可以是模型服务(6.2.2)或直接模型嵌入(6.2.1)。
模型服务方法
从 6.2.2 节的讨论中可以看出,模型服务方法包括为每个模型构建一个 Web 服务。因此,我们可以使用以下三个组件构建换脸模型应用程序:在手机上运行的前端 UI 应用程序(组件 A);用于处理用户操作的应用后端(组件 B);以及用于托管 deepfake 模型并公开 Web API 以执行每个换脸请求的后端服务,或预测器(组件 C)。
当用户在移动应用程序上上传源图片和目标图片,并点击换脸按钮时,移动后端应用程序将接收请求并调用预测器的 Web API 进行换脸。然后,预测器对用户请求数据(图片)进行预处理,执行模型算法,并对模型输出(图片)进行后处理,然后将其发送到应用后端。最终,移动应用程序将显示源图片和目标图片,并交换它们的人脸。图 6.4 描述了适用于换脸用例的一般设计。
图 6.4 在客户端/服务器设置中的单模型预测器设计
如果我们放大预测器(组件 C),我们会发现模型服务逻辑与我们在图 6.3 中介绍的一般模型预测工作流程相同。预测器(模型服务服务)从模型仓库中加载模型文件并运行模型以响应通过 Web 接口收到的请求。
图 6.4 中的设计通常适用于具有 Web 后端和只有一个模型的任何应用程序。该设计的关键组件是预测器;它是一个 Web 服务,通常作为 Docker 容器运行。我们可以快速实现这个方法,因为预测器容器可以从构建模型的训练容器中轻松转换。将训练容器转换为预测器容器的两个主要工作项是 Web 预测 API 和训练神经网络中的评估模式。我们将在第 7.1 节中介绍一个具体的预测器容器示例。
直接模型嵌入方法
构建单一模型应用的另一种设计方法是将模型执行代码与应用的用户逻辑代码结合起来。没有后端服务器,所有操作都在用户的计算机或手机上本地完成。以换脸应用为例,深度伪造模型文件在应用部署包中,当应用启动时,模型被加载到应用的进程空间中。图 6.5 展示了这个概念。
图 6.5 在直接模型嵌入设计中,模型在应用逻辑的同一进程中执行。
模型服务不一定要在独立的服务中运行。在图 6.5 中,我们可以看到模型服务代码(单一模型框)和数据转换代码可以与用户逻辑代码在同一个应用中运行。现在,很多深度学习框架都提供了在非 Python 应用中运行模型的库。例如,TensorFlow 提供了 Java、C++和 JavaScript 的 SDK 来加载和执行模型。借助 SDK 的帮助,我们可以直接在 Java/C++/JavaScript 应用中训练和执行模型。
注意为什么应该考虑直接模型嵌入?通过使用模型嵌入,我们可以直接将模型服务逻辑与应用逻辑集成并在同一个进程空间中运行它们。这相对于图 6.4 中的预测器服务方法有两个优势。首先,它减少了一次网络跳转;没有对预测器的 Web 请求,模型执行在本地进行。其次,它提高了服务的调试能力,因为我们可以将应用作为一个整体在本地运行。
为什么模型服务方法更受欢迎?
虽然直接模型嵌入方法看起来简单并且可以节省一次网络跳转,但它仍然不是构建模型服务的常见选择。以下是四个原因:
-
模型算法必须在不同的语言中重新实现。模型的算法和执行代码通常是用 Python 编写的。如果我们选择模型服务方法,将模型服务实现为 Web 服务(图 6.4 中的预测器),我们可以重用大部分训练代码并快速构建它。但是,如果我们选择将模型服务嵌入非 Python 应用程序中,我们必须在应用程序的语言中(如 Java 或 C ++)重新实现模型加载、模型执行和数据处理逻辑。这项工作并不简单,而且没有多少开发人员具备重写训练算法的深度知识。
-
所有权边界变得模糊。将模型嵌入应用程序时,业务逻辑代码可能会与服务代码混合在一起。当代码库变得复杂时,很难在服务代码(由数据科学家拥有)和其他应用程序代码(由开发人员拥有)之间划定界限。当数据科学家和开发人员来自两个不同的团队,但在同一个代码仓库上工作时,交叉团队的代码审查和部署时间会比平常长得多。
-
客户设备可能出现性能问题。通常,应用程序在客户的手机、平板电脑或低端笔记本电脑上运行。在这些设备上,从原始用户数据中捕获特征,然后预处理模型输入数据并运行模型预测可能会导致性能问题,如 CPU 使用率飙升、应用程序减速和内存使用量高。
-
内存泄漏很容易发生。例如,在 Java 中执行 TensorFlow 模型时,算法执行和输入/输出参数对象都是在本地空间中创建的。这些对象不会被 Java GC(垃圾收集)自动回收;我们必须手动释放它们。很容易忽视模型所声明的本地资源的回收,并且由于 Java 堆中不跟踪本地对象的内存分配,它们的内存使用量很难观察和测量。所以内存泄漏可能会发生,并且很难修复。
注意:为了排除本地内存泄漏,Jemalloc (github.com/jemalloc/jemalloc/wiki/Background
) 是一个非常方便的工具。您可以查看我的博客文章“在您的 Java 应用程序中修复内存问题” (mng.bz/lJ8o
) 获取更多详情。
出于前述原因,我们强烈建议您采用模型服务方法来处理单一模型应用用例。
6.3.2 多租户应用程序
我们将以聊天机器人应用程序作为示例来解释多租户用例。首先,让我们设定一下背景。租户是一家公司或组织(例如学校或零售店),他们使用聊天机器人应用程序与其客户进行沟通。租户使用相同的软件/服务-聊天机器人应用程序,但具有单独的带有其数据隔离的账户。聊天用户是租户的客户,使用聊天机器人与租户进行业务交流。
按设计,聊天机器人应用程序依靠意图分类模型从对话中识别用户的意图,然后将用户请求重定向到租户的正确服务部门。目前,该聊天机器人采用单一模型应用的方法,这意味着它为每个用户和租户使用单一的意图分类模型。
现在,由于租户反馈单一意图分类模型预测准确度低,我们决定让租户使用我们的训练算法,使用他们自己的数据集构建自己的模型。这样,模型可以更好地适应每个租户的业务情况。对于模型服务,我们将让租户使用自己的模型进行意图分类预测请求。当一个聊天机器人用户与聊天机器人应用程序交互时,应用程序将找到租户的特定模型来回答用户的问题。聊天机器人被改为多租户应用程序。
在这个聊天机器人多租户使用案例中,虽然这些模型属于不同的租户并使用不同的数据集进行训练,但它们属于相同类型的模型。因为这些模型使用相同的算法进行训练,它们的模型算法和预测函数都是相同的。我们可以通过添加模型缓存来扩展图 6.4 中的模型服务设计,以支持多租户。通过将模型图和其相关数据缓存在内存中,我们可以在一个服务中执行多租户模型服务。图 6.6 说明了这个概念。
与图 6.4 中的模型服务设计相比,图 6.6 中的设计增加了一个模型缓存(组件 A)和一个模型文件服务器(组件 B)。因为我们希望在一个服务中支持多个模型,所以我们需要一个内存中的模型缓存来托管和执行不同的模型。模型文件服务器存储可以加载到预测服务模型缓存中的模型文件。模型服务器也可以在预测服务实例之间共享。
要构建一个良好的模型缓存,我们需要考虑模型缓存管理和内存资源管理。对于模型缓存,我们需要分配一个唯一的模型 ID 作为缓存键,以识别缓存中的每个模型。例如,我们可以使用模型训练运行 ID 作为模型 ID;好处是,对于缓存中的每个模型,我们都可以追踪到是哪个训练运行生成了它。另一种更灵活的构建模型 ID 的方式是结合模型名称(自定义字符串)和模型版本。无论我们选择哪种模型 ID 样式,ID 必须是唯一的,并且必须在预测请求中提供。
图 6.6 面向多租户应用的模型缓存预测服务
对于内存资源管理,因为每台服务器的内存和 GPU 资源都是有限的,我们无法将所有所需的模型都加载到内存中。因此,我们需要构建模型交换逻辑到模型缓存中。当资源容量达到时——例如,进程即将耗尽内存时——需要从模型缓存中将一些模型驱逐出去,为新的模型预测请求释放一些资源。像 LRU(最近最少使用)算法和模型在不同实例之间的分区可以帮助减少缓存未命中率(请求的模型不在缓存中),并使模型交换更少地造成中断。我们在第 7.1 节中构建的样本意图分类预测服务演示了模型缓存的概念;你可以在那里探索详细信息。
我们可以将模型缓存设计扩展到多个模型类型吗?
我们不建议将模型缓存设计扩展到多个模型类型。各种模型类型的输入/输出数据格式和数据处理逻辑,如图像分类模型和意图分类模型,都非常不同,因此很难在同一个模型缓存中托管和执行不同类型的模型。为此,我们需要为每种模型类型构建单独的 Web 接口以及单独的数据预处理和后处理代码。在这一点上,你会发现为每种模型类型构建单独的预测服务更容易一些——每个服务都有自己的 Web 接口类型和数据处理逻辑,并管理其自己模型类型的模型缓存。例如,我们可以为这两种不同类型的模型分别构建图像分类预测服务和意图分类预测服务。
当你只有少量模型类型时,每种模型类型一个服务的方法效果很好。但如果你有 20 多种模型类型,那么它就无法扩展。构建和维护 Web 服务,比如设置 CI/CD 管道、网络和部署,成本很高。此外,监控服务的工作也不容易;我们需要建立监控和报警机制,以确保服务 24/7 运行。如果我们按照这种设计支持整个公司的 100+模型类型,考虑到入职和维护工作的成本。为了扩展规模并在一个系统中提供多种不同的模型类型,我们需要采取模型服务器方法(第 6.2.3 节),我们将在下一节进一步讨论。
6.3.3 在一个系统中支持多个应用程序
你已经成功地构建了多个模型服务以支持不同的应用程序,比如多租户聊天机器人、换脸、花卉识别和 PDF 文档扫描。现在,你又有两个任务:(1)为使用语音识别模型的新应用程序构建模型服务支持;(2)减少所有应用程序的模型服务成本。
到目前为止,所有的模型服务实现都是采用模型服务方法构建的。从前面第 6.3.1 节和第 6.3.2 节的讨论中,我们知道当我们有越来越多的模型类型时,这种方法无法扩展。当许多产品和应用程序都有模型服务需求时,最好只构建一个集中的预测服务来解决所有的服务需求。我们将这种类型的预测服务称为预测平台。它采用了模型服务器方法(第 6.2.3 节),并在一个地方处理所有类型的模型服务。对于多个应用程序情况来说,这是最具成本效益的方法,因为模型入职和维护成本仅限于一个系统,远远低于每个应用程序一种预测服务方法(第 6.2.2 节)。
要构建这样一个全能的模型服务系统,我们需要考虑很多因素,比如模型文件格式、模型库、模型训练框架、模型缓存、模型版本控制、模型流执行、模型数据处理、模型管理,以及适合所有模型类型的统一预测 API。图 6.7 展示了预测平台的设计和工作流程。
图 6.7 中的预测平台设计比图 6.6 中的模型服务方法复杂得多。这是因为我们需要组合多个组件和服务来支持任意模型。让我们来看看系统的每个组件,然后是模型预测工作流程。
图 6.7 通用的预测服务(平台)设计,适用于任意模型类型
统一的 Web API
为了支持任意模型,我们希望公共预测 API 是通用的。无论调用哪个模型,API 的规范——例如预测请求和响应的有效载荷模式——都应该足够通用,以满足模型的算法需求。这种统一 API 的一个示例是 KFServing 的预测协议(mng.bz/BlB2
),该协议旨在为任何模型和各种预测后端标准化预测协议。
Web API 也应该简单易懂,这样我们就能减少客户的接入和维护工作量。预测 API 可以分为三种类型:模型预测请求 API、模型元数据获取 API 和模型部署 API。模型元数据获取 API 和部署 API 非常有用,因为它们对于它们所提供的模型是无所不知的。我们需要这些方法来检查模型元数据,例如模型版本和算法信息,以及检查模型部署状态。
路由组件
通常,每种类型的服务后端只能处理几种类型的模型。为了支持任意模型,我们需要有不同种类的服务后端,例如 TensorFlow Serving 后端用于 TensorFlow 模型,TorchServe 后端用于 PyTorch 模型。当接收到模型预测请求时,系统需要知道哪个后端可以处理它。这是通过路由组件来完成的。
路由组件负责将预测请求路由到正确的后端推理服务器。对于给定的请求,路由组件首先获取模型的元数据;元数据包括模型算法名称和版本、模型版本和训练框架。然后,通过将模型元数据与路由配置进行匹配,确定应该将预测请求路由到哪个推理后端。
图执行组件
图执行组件处理需要执行一系列模型预测的预测类型。例如,为了自动化抵押贷款批准流程,我们必须按照以下三个模型的顺序运行贷款批准预测请求:一个 PDF 扫描模型来解析贷款申请的文本,一个命名实体识别模型来识别关键词,以及一个贷款评分模型来评分贷款申请。为了支持这种需求,我们可以定义一个有向无环图(DAG)来描述模型执行链,并构建一个图执行引擎以一次性执行。
推理服务器
推理(模型)服务器通过管理模型缓存和模型预测执行来执行实际的模型预测工作。它类似于图 6.6 中显示的预测服务,但更加复杂,因为它需要支持任意模型算法。除了预测 API 之外,推理服务器还应该提供模型管理 API,以实现注册新模型和通过编程方式删除模型的功能。
构建推理服务器比构建预测器服务复杂得多;很少有工程师愿意尝试。但幸运的是,有多个黑盒开源方法可以直接使用,例如 TensorFlow Serving、TorchServe 和 NVIDIA Triton Inference Server。在实践中,我们经常重用这些现有的开源推理服务器,并将它们集成到我们自己的路由组件和图执行组件中。我们将在第七章中更多地讨论开源模型服务器工具。
应用场景
在图 6.7 中,我们看到应用程序 A、B 和 C 共享同一模型服务后端。不同应用程序的模型服务发生在同一地方。与图 6.6 中的模型服务设计相比,预测平台更具可扩展性和更具成本效益,因为添加新应用程序 D 几乎没有任何入职成本。
例如,如果我们想要引入新的应用程序 D——一个语音转文本脚本应用程序——我们只需将语音脚本模型上传到模型文件服务器,然后让该应用程序使用预测平台的统一预测 web API。对于支持新应用程序,预测平台端不需要进行任何代码更改。
模型预测工作流程
在解释每个关键组件之后,让我们看一个典型的模型预测工作流程(图 6.7)。首先,我们将我们的模型文件发布到模型文件服务器,并更新路由组件中的配置,使路由组件知道应该将这种类型的模型的预测请求路由到哪个推理服务器。其次,应用程序向预测系统的 web API 发送预测请求,然后路由组件将请求路由到正确的推理服务器。第三,推理服务器将从模型文件服务器加载模型,将请求载荷转换为模型输入,运行模型算法,并以后处理方式返回预测结果。
注意 预测平台设计并不总是最佳的服务方法!理论上,图 6.7 中的设计可以适用于任何模型,但它确实带来了一些额外的成本。它的设置、维护和调试比模型服务方法要复杂得多。这种设计对于第 6.3.1 节和第 6.3.2 节中介绍的情景来说是过度的。因为每种设计都有其优点,我们建议不要坚持一个服务方法,而是根据实际用户场景选择服务方法。
6.3.4 常见的预测服务需求
尽管我们声明设计预测服务应该从具体的用例开始,但不同的情况会导致不同的设计。所有模型服务设计中存在三个共同的要求:
-
模型部署安全性——无论我们选择什么样的模型部署策略和版本策略,我们都必须有一种方法将模型回滚到先前的状态或版本。
-
延迟 — 网络请求延迟是许多在线业务成功的关键因素。一旦我们建立了模型服务支持,下一步就是尽力减少平均预测响应时间。
-
监控和警报 — 模型服务是深度学习系统中最关键的服务;如果它停止运行,业务也会停止。请记住,实际业务是实时运行在模型预测之上的。如果服务停止或服务延迟增加,客户会立即受到影响。在监控和警报方面,预测服务应该是其他深度学习服务中配备最齐全的服务。
在本章中,我们回顾了模型服务的概念、定义和抽象的高级系统设计。我们希望您能清楚地了解什么是模型服务以及在设计模型服务系统时要考虑什么。在下一章中,我们将演示两个样本预测服务,并讨论常用的预测开源工具。这些示例将展示本章中的设计概念如何应用于实际生活中。
摘要
-
一个模型可以是几个文件;它由三个元素组成:机器学习算法、模型执行器(包装器)和模型数据。
-
模型预测和模型推断在模型服务环境中具有相同的含义。
-
直接模型嵌入、模型服务和模型服务器是模型服务策略的三种常见类型。
-
模型服务方法涉及为每个模型、每个模型版本或每种类型的模型构建一个预测服务。
-
模型服务器方法包括仅构建一个预测服务,但它可以运行使用不同算法和框架训练的模型,并且可以运行每个模型的不同版本。
-
在设计模型服务系统时,首先要了解的是使用情况,这样我们就可以决定哪种服务方法最合适。
-
成本效益是设计模型服务系统的主要目标;成本包括服务部署、维护、监控、基础设施和服务开发。
-
对于单一模型应用程序,我们建议采用模型服务方法。
-
对于多租户应用程序,我们建议采用带有内存模型缓存的模型服务方法。
-
对于支持具有不同类型模型的多个应用程序,模型服务器和预测平台是最合适的方法。它们包括统一的预测 API、路由组件、图执行组件和多个模型服务器后端。
第七章:模型服务实践
本章涵盖
-
使用模型服务方法构建样本预测器
-
使用 TorchServe 和模型服务器方法构建样本服务
-
参观流行的开源模型服务库和系统
-
解释生产模型发布流程
-
讨论后期模型监控
在上一章中,我们讨论了模型服务的概念,以及用户场景和设计模式。在本章中,我们将重点放在这些概念在生产环境中的实际实现上。
正如我们所说,当前实施模型服务的挑战之一是我们有太多可能的做法。除了多个黑盒解决方案之外,还有许多定制和从头开始构建全部或部分模型服务的选项。我们认为教您如何选择正确方法的最佳方式是通过具体的例子。
在本章中,我们实现了两个示例服务,演示了两种最常用的模型服务方法:一种使用自建模型服务容器,演示了模型服务方法(第 7.1 节),另一种使用 TorchServe(用于 PyTorch 模型的模型服务器),演示了模型服务器方法(第 7.2 节)。这两种都用于第三章训练的意图分类模型。一旦您完成了示例,我们将提供(在第 7.3 节中)对最受欢迎的开源模型服务工具的介绍,以帮助您了解它们的特性、最佳用法和其他对您决定使用哪种工具的重要因素。在本章的其余部分,我们将重点关注模型服务操作和监控,包括将模型部署到生产环境并监控模型性能。
通过阅读本章,您不仅将对不同的模型服务设计有具体的理解,还将具备选择适合自己情况的正确方法的眼光。更重要的是,本章将全面呈现模型服务领域的视角,不仅仅是构建模型服务,还有运行和监控模型服务系统在构建后的过程。
注意:在本章中,术语模型服务、模型推断和模型预测是可以互换使用的。它们都指的是使用给定数据点执行模型。
7.1 模型服务示例
在本节中,我们将向您展示第一个样本预测服务。该服务采用了模型服务方法(第 6.2.2 节),可用于单模型(第 6.3.1 节)和多租户应用(第 6.3.2 节)。
此示例服务遵循单一模型应用设计(第 6.3.1 节),其中包含前端 API 组件和后端预测器。我们还对预测器进行了一些增强,以支持多个意图分类模型。我们将按照以下步骤对此示例服务进行参观:
-
在本地运行示例预测服务
-
讨论系统设计
-
查看其子组件的实现细节:前端服务和后端预测器
7.1.1 与服务玩耍
列表 7.1 显示了如何在本地机器上运行示例预测服务。以下脚本首先运行后端预测器,然后运行前端服务。
注意设置预测服务有点繁琐;我们需要运行元数据和艺术品存储服务,并准备好模型。为了清晰地演示这个想法,列表 7.1 强调了主要的设置步骤。要使模型服务在您的本地机器上工作,请完成附录 A 中的实验(A.2 节),然后使用代码 ./scripts/lab-004-model-serving.sh
{run_id}
{document}
发送模型预测请求。
列表 7.1 启动预测服务
# step 1: start backend predictor service
docker build -t orca3/intent-classification-predictor:latest \ ❶
-f predictor/Dockerfile predictor
docker run --name intent-classification-predictor \ ❷
--network orca3 --rm -d -p "${ICP_PORT}":51001 \ ❷
-v "${MODEL_CACHE_DIR}":/models \ ❷
orca3/intent-classification-predictor:latest
# step 2: start the prediction service (the web api)
docker build -t orca3/services:latest -f \ ❸
services.dockerfile .
docker run --name prediction-service --network orca3 \ ❹
--rm -d -p "${PS_PORT}":51001 -v "${MODEL_CACHE_DIR}":/tmp/modelCache \
orca3/services:latest prediction-service.jar
❶ 构建预测器 Docker 镜像
❷ 运行预测器服务容器
❸ 构建预测服务镜像
❹ 运行预测服务容器
一旦服务启动,您就可以向其发送预测请求;服务将加载第三章训练的意图分类模型,对给定文本进行模型预测,并返回预测结果。在以下示例中,将文本字符串“merry christmas”发送到服务,并预测为“joy”类别:
#./scripts/lab-004-model-serving.sh 1 "merry christmas"
grpcurl -plaintext
-d "{
"runId": "1", ❶
"document": "merry christmas" ❷
}"
localhost:"${PS_PORT}"
prediction.PredictionService/Predict
model_id is 1 ❸
document is hello world ❸
{
"response": "{\"result\": \"joy\"}" ❸
}
❶ 将模型 ID 指定给响应
❷ 预测负载
❸ 预测响应,预测类别
7.1.2 服务设计
此示例服务由前端界面组件和后端预测器组成。前端组件有三个功能:托管公共预测 API、从元数据存储下载模型文件到共享磁盘卷,并将预测请求转发给后端预测器。后端预测器是一个自建的预测器容器,用于响应加载意图分类模型并执行这些模型以服务于预测请求。
此预测服务有两个外部依赖项:元数据存储服务和共享磁盘卷。元数据存储保存有关模型的所有信息,例如模型算法名称、模型版本和指向真实模型文件的云存储的模型 URL。共享卷使前端服务和后端预测器之间能够共享模型文件。您可以在图 7.1 中看到模型服务过程的端到端概述。
图 7.1 系统概述和模型服务端到端工作流程
浏览图 7.1 中显示的样本模型服务的系统设计,您可以看到完成预测请求需要六个步骤。让我们逐步浏览图中编号的每一步:
-
用户向预测服务(前端组件)发送带有指定模型 ID 和文本字符串(即,
document
)的预测请求。模型 ID 是训练服务生成的唯一标识符,用于识别其生成的每个模型。 -
前端服务通过搜索模型 ID 从元数据存储中获取模型元数据。对于每个成功的模型训练,训练服务将模型文件保存到云存储中,并将模型元数据(模型 ID、模型版本、名称和 URL)保存到元数据存储中;这就是为什么我们可以在元数据存储中找到模型信息的原因。
-
如果模型文件尚未下载,前端组件将会将其下载到共享磁盘卷上。
-
前端组件将推理请求转发给后端预测器。
-
后端预测器通过从共享磁盘卷上的模型文件中读取,将意图分类模型加载到内存中。
-
后端预测器执行模型,对给定的文本字符串进行预测,并将预测结果返回给前端组件。
7.1.3 前端服务
现在,让我们重点关注前端服务。前端服务主要由三个组件组成:Web 接口、预测器管理器和预测器后端客户端(CustomGrpcPredictorBackend
)。这些组件响应主机公共 gRPC 模型提供 API,并管理后端预测器的连接和通信。图 7.2 显示了前端服务的内部结构以及在接收到预测请求时的内部工作流程。
图 7.2 前端服务设计和模型提供工作流程
接下来我们考虑在图 7.2 中描述的模型提供工作流程中的意图预测场景,并应用刚刚复习过的六个步骤:
-
用户向 Web 接口发送包含模型 ID A 的意图预测请求。
-
Web 接口调用预测器连接管理器来提供此请求。
-
预测器连接管理器通过查询元数据存储获取模型元数据,查询的条件为模型 ID 等于 A;返回的模型元数据包含模型算法类型和模型文件 URL。
-
基于模型算法类型,预测器管理器选择合适的预测器后端客户端来处理请求。在这种情况下,它选择了
CustomGrpcPredictorBackend
,因为我们正在演示用于意图分类的自建模型提供容器。 -
CustomGrpcPredictorBackend
客户端首先在模型 A 的共享模型文件磁盘中检查模型文件的存在。如果在以前没有下载过模型,则使用模型 URL(从模型元数据中获取)从云存储中下载模型文件到共享文件磁盘。 -
CustomGrpcPredictorBackend
客户端然后调用在服务配置文件中与该后端客户端预注册的模型预测器。在此示例中,CustomGrpcPredictorBackend
将调用我们自建的预测器,即意图预测器,将在第 7.1.4 节中讨论。
现在我们已经审查了系统设计和工作流程,让我们考虑主要组件的实际代码实现,包括 Web 接口(预测 API)、预测器连接管理器、预测器后端客户端和意图预测器。
前端服务模型服务代码演示
以下代码清单突出了图 7.2 中提到的预测工作流的核心实现。你也可以在 src/main/
java/org/orca3/miniAutoML/prediction/PredictionService.java
找到完整的实现。
7.2 前端服务预测工作流
public void predict(PredictRequest request, .. .. ..) {
.. .. ..
String runId = request.getRunId(); ❶
if (predictorManager.containsArtifact(runId)) { ❷
artifactInfo = predictorManager.getArtifact(runId);
} else {
try {
artifactInfo = msClient.getArtifact( ❷
GetArtifactRequest.newBuilder()
.setRunId(runId).build());
} catch (Exception ex) {
.. .. ..
}
}
# Step 4, pick predictor backend client by model algorithm type
PredictorBackend predictor;
if (predictorManager.containsPredictor(
artifactInfo.getAlgorithm())) {
predictor = predictorManager.getPredictor( ❸
artifactInfo.getAlgorithm());
} else {
.. .. ..
}
# Step 5, use the selected predictor client to download the model files
predictor.downloadModel(runId, artifactInfo); ❹
# Step 6, use the selected predictor client to call
# its backend predictor for model serving
String r = predictor.predict( ❺
artifactInfo, request.getDocument()); ❺
.. .. ..
}
❶ 获取所需的模型 ID
❷ 从元数据存储中获取模型元数据
❸ 根据模型算法类型选择后端预测器
❹ 下载模型文件
❺ 调用后端预测器运行模型推理
预测 API
前端服务仅提供一个 API — Predict
— 用于发出预测请求。该请求有两个参数,runId
和 document
。runId
不仅用于在训练服务(第三章)中引用模型训练运行,还可以用作引用模型的模型 ID。document
是客户想要运行预测的文本。
通过使用 Predict
API,用户可以指定一个意图模型(带有 runId
)来预测给定文本字符串(document
)的意图。以下清单显示了 Predict
API 的 gRPC 合同(grpc-contract/src/main/proto/prediction_service .proto
)。
7.3 预测服务 gRPC 接口
service PredictionService {
rpc Predict(PredictRequest) returns (PredictResponse);
}
message PredictRequest {
string runId = 3;
string document = 4;
}
message PredictResponse {
string response = 1;
}
预测器连接管理器
前端服务的一个重要作用是路由预测请求。给定一个预测请求,前端服务需要根据请求中所需的模型算法类型找到正确的后端预测器。这个路由是在 PredictorConnectionManager
中完成的。在我们的设计中,模型算法和预测器的映射是预定义的在环境属性中。当服务启动时,PredictorConnectionManager
将读取映射,这样服务就知道为哪种模型算法类型使用哪个后端预测器。
尽管在这个示例中我们只是演示了我们自己构建的意图分类预测器,PredictorConnectionManager
可以支持任何其他类型的后端预测器。让我们看一下以下清单(config/config-docker-docker.properties
)来看看模型算法和预测器映射是如何配置的。
7.4 模型算法和预测器映射配置
# the algorithm and predictor mapping can be defined in
# either app config or docker properties
# enable algorithm types
ps.enabledPredictors=intent-classification
# define algorithm and predictors mapping
# predictor.<algorithm_type>.XXX = predictor[host, port, type]
predictors.intent-classification.host= \ ❶
Intent-classification-predictor ❶
predictors.intent-classification.port=51001
predictors.intent-classification.techStack=customGrpc
❶ 将意图分类预测器映射到意图分类算法
现在,让我们回顾代码清单 7.5,看看预测器管理器如何读取算法和预测器映射,并使用该信息初始化预测器后端客户端发送预测请求。完整的实现位于 prediction-service/src/main/java/org/orca3/miniAutoML/prediction/PredictorConnectionManager.java
。
7.5 预测器管理器加载算法和预测器映射
public class PredictorConnectionManager {
private final Map<String, List<ManagedChannel>>
channels = new HashMap<>();
private final Map<String, PredictorBackend> ❶
clients = new HashMap<>();
private final Map<String, GetArtifactResponse> ❷
artifactCache;
// create predictor backend objects for
// the registered algorithm and predictor
public void registerPredictor(String algorithm,
Properties properties) {
String host = properties.getProperty( ❸
String.format(“predictors.%s.host”, algorithm));
int port = Integer.parseInt(properties.getProperty( ❸
String.format(“predictors.%s.port”, algorithm)));
String predictorType = properties.getProperty( ❸
String.format(“predictors.%s.techStack”, algorithm));
ManagedChannel channel = ManagedChannelBuilder
.forAddress(host, port)
.usePlaintext().build();
switch (predictorType) {
.. ..
case “customGrpc”: ❹
default:
channels.put(algorithm, List.of(channel));
clients.put(algorithm, new CustomGrpcPredictorBackend(
channel, modelCachePath, minioClient));
break;
}
}
.. .. ..
}
❶ 预测器后端映射的算法
❷ 模型元数据缓存; 键字符串为模型 ID。
❸ 从配置中读取算法和预测器映射
❹ 创建预测器后端客户端并将其保存在内存中
在列表 7.5 中,我们可以看到 PredictorConnectionManager
类提供了registerPredictor
函数来注册预测器。它首先从属性中读取算法和预测器映射信息,然后创建实际的预测器后端客户端CustomGrpcPredictorBackend
与后端意图预测器容器通信。
您还可以注意到 PredictorConnectionManager
类有几个缓存,如模型元数据缓存(artifactCache
)和模型后端预测器客户端(clients
)。这些缓存可以极大地提高模型服务的效率。例如,模型元数据缓存(artifactCache
)可以通过避免调用元数据存储服务来减少呼叫已经下载的模型的服务请求响应时间。
预测器后端客户端
预测器客户端是前端服务用于与不同的预测器后端进行通信的对象。按设计,每种类型的预测器后端都支持其自己的模型类型,并且它有自己的用于通信的客户端,该客户端在PredictorConnectionManager
中创建并存储。每个预测器后端客户端都会继承一个名为PredictorBackend
的接口,如下列表所示。
列表 7.6 预测器后端接口
public interface PredictorBackend {
void downloadModel(String runId,
GetArtifactResponse artifactResponse);
String predict(GetArtifactResponse artifact, String document);
void registerModel(GetArtifactResponse artifact);
}
downloadMode
、predict
和registerModel
三个方法都是不言自明的。每个客户端实现这些方法来下载模型并向其注册的后端服务发送预测请求。GetArtifactResponse
参数是从元数据存储中获取的模型元数据对象。
在这个(意图预测器)示例中,预测器后端客户端是CustomGrpcPredictorBackend
。您可以在prediction-service/src/main/java/org/orca3/miniAutoML/prediction/CustomGrpcPredictorBackend.java
中找到详细的实现。下面的代码片段展示了该客户端如何使用 gRPC 协议将预测请求发送到自建的意图预测器容器:
// calling backend predictor for model serving
public String predict(GetArtifactResponse artifact, String document) {
return stub.predictorPredict(PredictorPredictRequest
.newBuilder().setDocument(document) ❶
.setRunId(artifact.getRunId()) ❷
.build()).getResponse();
}
❶ 模型的文本输入
❷ 模型 ID
7.1.4 意图分类预测器
我们已经看到了前端服务及其内部路由逻辑,现在让我们来看看这个示例预测服务的最后一部分:后端预测器。为了向您展示一个完整的深度学习用例,我们实现了一个预测器容器来执行第三章训练的意图分类模型。
我们可以将这个自建的意图分类预测器视为一个独立的微服务,可以同时为多个意图模型提供服务。它具有 gRPC web 接口和模型管理器。模型管理器是预测器的核心;它执行多项任务,包括加载模型文件,初始化模型,将模型缓存在内存中,并使用用户输入执行模型。图 7.3 显示了预测器的设计图和预测器内的预测工作流程。
让我们使用图 7.3 中的模型 A 的意图预测请求来考虑工作流程。它按以下步骤运行:
-
前端服务中的预测客户端调用预测器的 web gRPC 接口,使用模型 A 运行意图预测。
-
请求调用模型管理器。
-
模型管理器从共享磁盘卷加载模型 A 的模型文件,初始化模型,并将其放入模型缓存中。模型文件应该已经由前端服务放置在共享磁盘卷上。
-
模型管理器使用转换器的帮助执行模型 A,对输入和输出数据进行预处理和后处理。
-
返回预测结果。
图 7.3 后端意图预测器设计和预测工作流程
接下来,让我们看看工作流程中提到的组件的实际实现。
预测 API
意图预测器有一个 API — PredictorPredict
(见代码列表 7.7)。它接受两个参数,runId
和 document
。runId
是模型 ID,document
是一个文本字符串。你可以在 grpc-contract/src/main/proto/
prediction_service.proto
中找到完整的 gRPC 合同。
列表 7.7 意图预测器 gRPC 接口
service Predictor {
rpc PredictorPredict(PredictorPredictRequest) returns (PredictorPredictResponse);
}
message PredictorPredictRequest {
string runId = 1;
string document = 2;
}
message PredictorPredictResponse {
string response = 1;
}
你可能注意到预测器 API 与前端 API(代码列表 7.2)相同;这是为了简单起见。但在实际应用中,它们通常是不同的,主要是因为它们被设计用于不同的目的。预测器的 predict API 设计有利于模型执行,而前端的 predict API 设计有利于客户和业务的需求。
模型文件
我们在模型训练服务(第三章)中生成的每个意图分类模型都有三个文件。manifest.json
文件包含模型元数据和数据集标签;预测器需要这些信息将模型预测结果从整数转换为有意义的文本字符串。model.pth
是模型的学习参数;预测器将读取这些网络参数以设置模型的神经网络以进行模型服务。vocab.pth
是模型训练中使用的词汇文件,这也是服务所必需的,因为我们需要它将用户输入(字符串)转换为模型输入(十进制数)。让我们来看一下示例意图模型:
├── manifest.json ❶
├── model.pth ❷
└── vocab.pth ❸
// A sample manifest.json file
{
"Algorithm": "intent-classification", ❹
"Framework": "Pytorch",
"FrameworkVersion": "1.9.0",
"ModelName": "intent",
"CodeVersion": "80bf0da",
"ModelVersion": "1.0",
"classes": { ❺
"0": "cancel",
"1": "ingredients_list",
"2": "nutrition_info",
"3": "greeting",
.. .. ..
}
❶ 模型元数据和数据集标签
❷ 模型权重文件
❸ 词汇文件
❹ 模型元数据
❺ 数据集标签
当保存 PyTorch 模型时,有两种选择:序列化整个模型或仅序列化学习参数。第一种选项序列化整个模型对象,包括其类和目录结构,而第二种选项仅保存模型网络的可学习参数。
根据马修·英卡威奇的文章“PyTorch: Saving and Loading Models”(mng.bz/zm9B
),PyTorch 团队建议仅保存模型的学习参数(模型的state_dict
)。如果我们保存整个模型,序列化数据将与保存模型时使用的特定类和确切目录结构绑定。模型类本身不会被保存;而是保存包含类的文件。因此,在加载时,当在其他项目中使用或进行重构后,序列化的模型代码可能会以各种方式中断。
为此,我们只保存模型的state_dict
(学习参数)作为训练后的模型文件;在这个例子中,它是model.pth
文件。我们使用以下代码保存它:torch.save(model.state_dict(), model_local_path)
。因此,预测器需要知道模型的神经网络架构(见代码清单 7.8)来加载模型文件,因为模型文件只是state_dict
——模型网络的参数。
清单 7.8(predictor/predict.py
)显示了我们用来在预测器中加载模型文件model.pth
(仅参数)的模型架构。服务中的模型执行代码源自模型训练代码。如果你将以下清单中的模型定义与我们训练代码中的TextClassificationModel
类(training-code/text-classification/train.py
)进行比较,你会发现它们是相同的。这是因为模型服务本质上是模型训练运行。
清单 7.8 模型的神经网络(架构)
class TextClassificationModel(nn.Module):
def __init__(self, vocab_size, embed_dim, ❶
fc_size, num_class): ❶
super(TextClassificationModel, self).__init__()
self.embedding = nn.EmbeddingBag(vocab_size, embed_dim, sparse=True)
self.fc1 = nn.Linear(embed_dim, fc_size)
self.fc2 = nn.Linear(fc_size, num_class)
self.init_weights()
def forward(self, text, offsets):
embedded = self.embedding(text, offsets)
return self.fc2(self.fc1(embedded))
❶ 定义模型架构
你可能会想知道训练代码和模型服务代码是否现在合并了。当训练代码发生变化时,似乎预测器中的模型服务代码也需要调整。这只是部分正确;上下文往往会决定模型服务如何受到模型训练算法变化的影响。以下是这种关系的一些微妙之处。
首先,训练代码和服务代码只需在神经网络架构和输入/输出数据模式上同步。其他模型训练变化,比如训练策略、超参数调整、数据集拆分和增强,不会影响服务,因为它们会产生模型权重和偏置文件。其次,在训练时应引入模型版本控制。在实践中,每次模型训练或重新训练都会给输出模型分配一个新的模型版本。所以要解决的问题是如何为模型的不同版本提供服务。
这个示例服务不处理模型版本管理。但是,在第 7.5 节和第八章中,我们将深入讨论模型版本的元数据管理。我们在这里只是描述了大致的想法。
如果你正在使用类似的模型服务方法,并且有一个自定义的预测器后端,你需要准备多个版本的预测器后端,以匹配使用不同神经网络架构训练的模型。在发布模型时,训练代码的版本、服务代码的版本以及模型文件的版本需要作为模型元数据的一部分相关联,并保存在元数据存储中。因此,在提供服务时,预测服务(前端服务)可以搜索元数据存储,以确定应将请求路由到给定模型的哪个预测器版本。
如果你使用模型服务器方法,使用不同版本的模型变得更加容易,因为这种方法打破了服务代码(模型执行代码)和训练代码之间的依赖关系。你可以在第 7.2 节中看到一个具体的例子。
注意:正如我们在第六章(第 6.1.3 节)中提到的,模型训练和服务都利用相同的机器学习算法,但是在不同的执行模式下:学习和评估。然而,我们想再次澄清这个概念。理解训练代码、服务代码和模型文件之间的关系是服务系统设计的基础。
模型管理器
模型管理器是这个意图预测器的关键组件。它托管一个内存模型缓存,加载模型文件,并执行模型。下面的清单(predictor/predict.py
)显示了模型管理器的核心代码。
清单 7.9 意图预测器模型管理器
class ModelManager:
def __init__(self, config, tokenizer, device):
self.model_dir = config.MODEL_DIR
self.models = {} ❶
# load model file and initialize model
def load_model(self, model_id):
if model_id in self.models:
return
# load model files, including vocabulary, prediction class mapping.
vacab_path = os.path.join(self.model_dir, model_id, "vocab.pth")
manifest_path = os.path.join(self.model_dir, model_id, "manifest.json")
model_path = os.path.join(self.model_dir, model_id, "model.pth")
vocab = torch.load(vacab_path)
with open(manifest_path, 'r') as f:
manifest = json.loads(f.read())
classes = manifest['classes']
# initialize model graph and load model weights
num_class, vocab_size, emsize = len(classes), len(vocab), 64
model = TextClassificationModel(vocab_size, emsize,
self.config.FC_SIZE, num_class).to(self.device)
model.load_state_dict(torch.load(model_path))
model.eval()
self.models[self.model_key(model_id)] = model ❷
self.models[self.model_vocab_key(model_id)] ❷
➥ = vocab ❷
self.models[self.model_classes(model_id)] ❷
➥ = classes ❷
# run model to make prediction
def predict(self, model_id, document):
# fetch model graph, dependency and
# classes from cache by model id
model = self.models[self.model_key(model_id)]
vocab = self.models[self.model_vocab_key(model_id)]
classes = self.models[self.model_classes(model_id)]
def text_pipeline(x):
return vocab(self.tokenizer(x))
# transform user input data (text string)
# to model graph’s input
processed_text = torch.tensor(text_pipeline(document), dtype=torch.int64)
offsets = [0, processed_text.size(0)]
offsets = torch.tensor(offsets[:-1]).cumsum(dim=0)
val = model(processed_text, offsets) ❸
# convert prediction result from an integer to
# a text string (class)
res_index = val.argmax(1).item()
res = classes[str(res_index)]
print("label is {}, {}".format(res_index, res))
return res
❶ 将模型托管在内存中
❷ 将模型图、内存中的依赖项和类缓存
❸ 运行模型以获取预测结果
意图预测器预测请求工作流程
你已经了解了意图预测器的主要组件,现在让我们来看看这个预测器内部的端到端工作流程。首先,我们通过将 PredictorServicer
注册到 gRPC 服务器来公开预测 API,这样前端服务就可以远程与预测器通信。其次,当前端服务调用 PredictorPredict
API 时,模型管理器将加载模型到内存中,运行模型,并返回预测结果。清单 7.10 突出了上述工作流程的代码实现。你可以在 predictor/predict.py
中找到完整的实现。
清单 7.10 意图预测器预测工作流程
def serve():
.. .. ..
model_manager = ModelManager(config,
tokenizer=get_tokenizer('basic_english'), device="cpu")
server = grpc.server(futures.
ThreadPoolExecutor(max_workers=10)) ❶
prediction_service_pb2_grpc.add_PredictorServicer_to_server(
PredictorServicer(model_manager), server) ❷
.. .. ..
class PredictorServicer(prediction_service_pb2_grpc.PredictorServicer):
def __init__(self, model_manager):
self.model_manager = model_manager
# Serving logic
def PredictorPredict(self, request, context: grpc.ServicerContext):
# load model
self.model_manager.load_model(model_id=request.runId)
class_name = self.model_manager. ❸
predict(request.runId, request.document)
return PredictorPredictResponse(response=json.dumps({'res': class_name}))
❶ 启动 gRPC 服务器
❷ 将模型服务逻辑注册到公共 API
❸ 进行预测
7.1.5 模型逐出
示例代码未涵盖模型淘汰——即从预测服务的内存空间中淘汰不经常使用的模型文件。在设计中,对于每个预测请求,预测服务将从元数据存储中查询和下载请求模型,然后从本地磁盘读取和初始化模型到内存中。对于一些模型来说,这些操作是耗时的。
为了减少每个模型预测请求的延迟,我们的设计在模型管理器组件(内存中)中缓存模型图,以避免模型加载已使用的模型。但想象一下,我们可以继续训练新的意图分类模型并对其进行预测。这些新产生的模型将继续加载到模型管理器的模型缓存中。最终,预测器将耗尽内存。
为了解决这些问题,模型管理器需要升级以包含模型淘汰功能。例如,我们可以引入 LRU(最近最少使用)算法来重建模型管理器的模型缓存。借助 LRU 的帮助,我们可以仅保留最近访问的模型在模型缓存中,并在当前加载的模型超过内存阈值时淘汰最少访问的模型。
7.2 TorchServe 模型服务器示例
在本节中,我们将向您展示使用模型服务器方法构建预测服务的示例。更具体地说,我们使用了 TorchServe 后端(一个为 PyTorch 模型构建的模型服务工具)来替换上一节(7.1.4)中讨论的自建预测器。
为了与第 7.1 节中的模型服务方法进行公平比较,我们通过重新使用上一节中展示的前端服务来开发此模型服务器方法示例。更确切地说,我们只添加了另一个预测器后端,仍然使用前端服务、gRPC API 和意图分类模型来演示相同的端到端预测工作流程。
第 7.1.4 节的意图预测器和 TorchServe 预测器(模型服务器方法)之间有一个很大的区别。相同的预测器可以为任何 PyTorch 模型提供服务,而不管其预测算法如何。
7.2.1 玩转服务
因为这个模型服务器示例是在上一个示例服务的基础上开发的,所以我们以相同的方式与预测服务交互。唯一的区别是我们启动了一个 TorchServe 后端(容器),而不是启动一个自建的意图预测器容器。代码清单 7.11 仅显示了启动服务和发送意图预测请求的关键步骤。要在本地运行实验,请完成附录 A(A.2 节)中的实验,并参考 scripts/lab-006-model-serving-torchserve.sh
文件(mng.bz/0yEN
)。
列表 7.11 启动预测服务并进行预测调用
# step 1: start torchserve backend
docker run --name intent-classification-torch-predictor\
--network orca3 --rm -d -p "${ICP_TORCH_PORT}":7070 \
-p "${ICP_TORCH_MGMT_PORT}":7071 \
-v "${MODEL_CACHE_DIR}":/models \ ❶
-v "$(pwd)/config/torch_server_config.properties": \
/home/model-server/config.properties \
pytorch/torchserve:0.5.2-cpu torchserve \ ❷
--start --model-store /models ❸
# step 2: start the prediction service (the web frontend)
docker build -t orca3/services:latest -f services.dockerfile .
docker run --name prediction-service --network orca3 \
--rm -d -p "${PS_PORT}":51001 \
-v "${MODEL_CACHE_DIR}":/tmp/modelCache \ ❹
orca3/services:latest \
prediction-service.jar
# step 3: make a prediction request, ask intent for “merry christmas”
grpcurl -plaintext
-d "{
"runId": "${MODEL_ID}",
"document": "merry christmas"
}"
localhost:"${PS_PORT}" prediction.PredictionService/Predict
❶ 将本地目录挂载到 TorchServe 容器
❷ 启动 TorchServe
❸ 设置 TorchServe 从 /models 目录加载模型
❹ 设置预测服务的本地模型目录以下载模型
7.2.2 服务设计
此示例服务遵循图 7.1 中的相同系统设计;唯一的区别是预测器后端变成了 TorchServe 服务器。请参阅图 7.4 以获取更新后的系统设计。
图 7.4 系统概述和模型服务端到端工作流程
从图 7.4 可以看出,模型服务工作流程与图 7.1 中的模型服务示例保持一致。用户调用预测服务的前端 API 发送模型服务请求;前端服务然后下载模型文件,并将预测请求转发到 TorchServe 后端。
7.2.3 前端服务
在第 7.1.3 节中,我们确认了前端服务可以通过在预测器连接管理器中注册预测器来支持不同的预测器后端。当预测请求到来时,预测器连接管理器将通过检查请求的模型算法类型将请求路由到适当的预测器后端。
遵循之前的设计,为了支持我们的新 TorchServe 后端,我们在前端服务中添加了一个新的预测器客户端(TorchGrpcPredictorBackend
)来代表 TorchServe 后端;请参阅图 7.5 以获取更新后的系统设计。
图 7.5 前端服务设计和模型服务工作流程
在图 7.5 中,添加了两个灰色的方框;它们分别是 TorchServe gRPC 预测器后端客户端(TorchGrpcPredictorBackend
)和后端 TorchServe 服务器。TorchGrpcPredictorBackend
通过下载模型文件并向 TorchServe 容器发送预测请求进行响应。在这个示例中,TorchServe 后端将由预测器连接管理器选择,因为请求的模型元数据(在元数据存储中)将 TorchServe 定义为其预测器。
7.2.4 TorchServe 后端
TorchServe 是由 PyTorch 团队构建的用于提供 PyTorch 模型服务的工具。TorchServe 作为一个黑盒运行,它提供 HTTP 和 gRPC 接口用于模型预测和内部资源管理。图 7.6 可视化了我们在这个示例中如何使用 TorchServe 的工作流程。
图 7.6 TorchServe 后端的模型服务工作流程:TorchServe 应用程序作为一个黑盒运行。
在我们的示例代码中,我们将 TorchServe 作为一个 Docker 容器运行,这是由 PyTorch 团队提供的,然后将本地文件目录挂载到容器中。这个文件目录作为 TorchServe 进程的模型存储。在图 7.6 中,我们分三步来运行模型预测。首先,我们将 PyTorch 模型文件复制到模型存储目录中。其次,我们调用 TorchServe 管理 API 将模型注册到 TorchServe 进程中。最后,我们调用 TorchServe API 来运行模型预测,对于我们来说,是意图分类模型。
跟第 7.1.4 节中的自构建意图预测器相比,TorchServe 要简单得多。我们甚至不需要编写任何代码就可以使模型服务正常运行,只需使用共享磁盘设置 Docker 容器即可。此外,TorchServe 不仅适用于意图分类算法,它不受任何特定训练算法的限制,只要模型是使用 PyTorch 框架训练的,TorchServe 就可以为其提供服务。
TorchServe 提供了极大的灵活性和便利性,但也有相关要求。TorchServe 要求操作员使用其独有的 API 发送模型服务请求,并要求模型文件以 TorchServe 格式打包。下面的两小节会详细介绍这些要求。
7.2.5 TorchServe API
TorchServe 提供了众多类型的 API,例如健康检查、模型解释、模型服务、工作线程管理和模型注册等。每个 API 都有 HTTP 和 gRPC 两种实现方式。由于 TorchServe 在其官网(pytorch.org/serve/
)和 GitHub 仓库(github.com/pytorch/serve
)上都对 API 的协议和使用方式进行了详细的解释,你可以在那里找到具体的信息。在本小节中,我们将着重介绍我们在示例服务中使用的模型注册 API 和模型推理 API。
模型注册 API
由于 TorchServe 采用黑箱方式进行模型服务,所以在使用模型之前需要将其注册。具体来说,我们需要先将模型文件放到 TorchServe 的模型存储库中(即本地文件目录),但是 TorchServe 并不会自动加载该模型文件。我们需要向 TorchServe 注册模型文件和该模型的运行方法,以便 TorchServe 知道如何正常运行该模型。
在我们的代码示例中,我们使用了 TorchServe 的 gRPC 模型注册 API 来从预测服务中注册我们的意图模型,示例如下:
public void registerModel(GetArtifactResponse artifact) {
String modelUrl = String.format(MODEL_FILE_NAME_TEMPLATE,
artifact.getRunId());
String torchModelName = String.format(TORCH_MODEL_NAME_TEMPLATE,
artifact.getName(), artifact.getVersion());
ManagementResponse r = managementStub.registerModel( ❶
RegisterModelRequest.newBuilder()
.setUrl(modelUrl)
.setModelName(torchModelName)
.build());
# Assign resource (TorchServe worker) for this model
managementStub.scaleWorker(ScaleWorkerRequest.newBuilder()
.setModelName(torchModelName)
.setMinWorker(1)
.build());
}
❶ 通过提供模型文件和模型名称向 TorchServe 注册模型
TorchServe 模型文件中已经包含有模型的元数据,包括模型版本、模型运行时和模型服务入口。因此,在注册模型时,通常只需要在registerModel
API 中设置模型文件名。除了模型注册之外,我们还可以使用scaleWorker
API 来控制为该模型分配多少计算资源。
模型推理 API
TorchServe 为各种模型提供了统一的模型服务 API,使其使用起来非常简单。如果想要为模型的默认版本运行预测,只需向/predictions/{model_name}
发送一个 REST 请求;如果想要为加载的特定版本的模型运行预测,则向/predictions/{model_name}/{version}
发送 REST 请求。需要预测的内容以二进制格式输入到预测请求中。例如:
# prediction with single input on model resnet-18
curl http:/ /localhost:8080/predictions/resnet-18 \
-F "data=@kitten_small.jpg"
# prediction with multiple inputs on model squeezenet1_1
curl http:/ /localhost:8080/predictions/squeezenet1_1 \
-F 'data=@docs/images/dogs-before.jpg' \
-F 'data=@docs/images/kitten_small.jpg'
在我们的样本服务中,我们使用 gRPC 接口将预测请求发送到 TorchServe。代码清单 7.12 展示了 TorchGrpcPredictorBackend
客户端将预测请求从前端 API 调用转换为 TorchServe 后端 gRPC 调用。您可以在 prediction-service/src/main/java/org/orca3/miniAutoML/prediction/TorchGrpcPredictorBackend.java
找到 TorchGrpcPredictorBackend
的完整源代码。
清单 7.12 从前端服务调用 TorchServe 预测 API
// call TorchServe gRPC prediction api
public String predict(GetArtifactResponse artifact, String document) {
return stub.predictions(PredictionsRequest.newBuilder()
.setModelName(String.format(TORCH_MODEL_NAME_TEMPLATE,
artifact.getName(), artifact.getVersion()))
.putAllInput(ImmutableMap.of("data", ❶
ByteString.copyFrom(document, StandardCharsets.UTF_8)))
.build()).getPrediction()
.toString(StandardCharsets.UTF_8);
}
❶ 将文本输入转换为二进制格式以调用 TorchServe
7.2.6 TorchServe 模型文件
到目前为止,您已经看到了 TorchServe 模型服务的工作流程和 API。您可能想知道当 TorchServe 对其所服务的模型一无所知时,TorchServe 的模型服务是如何工作的。在第六章中,我们学到要服务一个模型,预测服务需要知道模型算法和模型输入/输出模式。与直觉相反,TorchServe 运行模型服务而不知道模型算法和模型输入/输出数据格式。诀窍在于 TorchServe 模型文件。
TorchServe 要求模型打包到一个特殊的 .mar
文件中。我们可以使用 torch-model-archiver
CLI 或 model_archiver
Python 库将 PyTorch 模型文件打包成一个 .mar
文件。
要归档 TorchServe 的 .mar
文件,我们需要提供模型名称、模型文件(.pt
或 .pth
)和一个处理程序文件。处理程序文件是关键部分;它是一个定义处理自定义 TorchServe 推理逻辑的 Python 代码文件。因为 TorchServe 的模型包(.mar
文件)包含模型算法、模型数据和模型执行代码,而模型执行代码遵循 TorchServe 的预测接口(协议),所以 TorchServe 可以通过使用其通用预测 API 在不知道模型算法的情况下执行任何模型(.mar
文件)。
当 TorchServe 收到预测请求时,它首先会找到承载模型的内部工作进程,然后触发模型的处理程序文件来处理请求。处理程序文件包含四个逻辑部分:模型网络初始化、输入数据预处理、模型推理和预测结果后处理。为了使前面的解释更具体,让我们以我们的意图模型文件为例。
意图分类 .mar
文件
如果我们打开样本服务中意图模型的 .mar
文件,与我们在第 7.1.4 节中看到的模型文件相比,我们会看到额外添加了两个文件——MANIFEST.json
和 torchserve_handler.py
。以下是意图 .mar
文件的文件夹结构:
# intent.mar content
├── MAR-INF
│ └── MANIFEST.json ❶
├── manifest.json ❷
├── model.pth ❸
├── torchserve_handler.py ❹
└── vocab.pth ❺
# MANIFEST.json, TorchServe .mar metadata
{
"createdOn": "09/11/2021 10:26:59",
"runtime": "python",
"model": {
"modelName": "intent_80bf0da",
"serializedFile": "model.pth",
"handler": "torchserve_handler.py",
"modelVersion": "1.0"
},
"archiverVersion": "0.4.2"
}
❶ TorchServe .mar 文件元数据
❷ 包含标签信息
❸ 模型权重文件
❹ 模型架构和模型服务逻辑
❺ 词汇文件,意图算法所需
MANIFEST.json
文件定义了模型的元数据,包括模型版本、模型权重、模型名称和处理程序文件。通过拥有MANIFEST.json
文件,TorchServe 知道如何加载和运行预测任意模型,而不知道其实现细节。
TorchServe 处理程序文件
一旦模型在 TorchServe 中注册,TorchServe 将使用模型处理程序文件中的handle(self
, data
, context)
函数作为模型预测的入口点。处理程序文件管理模型服务的整个过程,包括模型初始化、对输入请求的预处理、模型执行和对预测结果的后处理。
代码清单 7.13 强调了在该示例服务中使用的意图分类 .mar 文件的处理程序文件中定义的关键部分。您可以在我们的 Git 代码库中找到此文件,路径为training-code/text-classification/torchserve_handler.py
。
列表 7.13 意图模型 TorchServe 处理文件
class ModelHandler(BaseHandler):
"""
A custom model handler implementation for serving
intent classification prediction in torch serving server.
"""
# Model architecture
class TextClassificationModel(nn.Module):
def __init__(self, vocab_size, embed_dim, fc_size, num_class):
super(ModelHandler.TextClassificationModel, self)
➥ .__init__()
self.embedding = nn.EmbeddingBag(vocab_size,
➥ embed_dim, sparse=True)
self.fc1 = nn.Linear(embed_dim, fc_size)
self.fc2 = nn.Linear(fc_size, num_class)
self.init_weights()
def init_weights(self):
.. .. ..
def forward(self, text, offsets):
embedded = self.embedding(text, offsets)
return self.fc2(self.fc1(embedded))
# Load dependent files and initialize model
def initialize(self, ctx):
model_dir = properties.get("model_dir")
model_path = os.path.join(model_dir, "model.pth")
vacab_path = os.path.join(model_dir, "vocab.pth")
manifest_path = os.path.join(model_dir, "manifest.json")
# load vocabulary
self.vocab = torch.load(vacab_path)
# load model manifest, including label index map.
with open(manifest_path, 'r') as f:
self.manifest = json.loads(f.read())
classes = self.manifest['classes']
# intialize model
self.model = self.TextClassificationModel(
vocab_size, emsize, self.fcsize, num_class).to("cpu")
self.model.load_state_dict(torch.load(model_path))
self.model.eval()
self.initialized = True
# Transform raw input into model input data.
def preprocess(self, data):
preprocessed_data = data[0].get("data")
if preprocessed_data is None:
preprocessed_data = data[0].get("body")
text_pipeline = lambda x: self.vocab(self.tokenizer(x))
user_input = " ".join(str(preprocessed_data))
processed_text = torch.tensor(text_pipeline(user_input),
dtype=torch.int64)
offsets = [0, processed_text.size(0)]
offsets = torch.tensor(offsets[:-1]).cumsum(dim=0)
return (processed_text, offsets)
# Run model inference by executing the model with model input
def inference(self, model_input):
model_output = self.model.forward(model_input[0], model_input[1])
return model_output
# Take output from network and post-process to desired format
def postprocess(self, inference_output):
res_index = inference_output.argmax(1).item()
classes = self.manifest['classes']
postprocess_output = classes[str(res_index)]
return [{"predict_res":postprocess_output}]
# Entry point of model serving, invoke by TorchServe
# for prediction request
def handle(self, data, context):
model_input = self.preprocess(data)
model_output = self.inference(model_input)
return self.postprocess(model_output)
从清单 7.13 中的handle
函数开始,您将清楚地了解处理程序文件是如何执行模型服务的。initialize
函数加载所有模型文件(权重、标签和词汇表)并初始化模型。handle
函数是模型服务的入口点;它对二进制模型输入进行预处理,运行模型推断,对模型输出进行后处理,并返回结果。
在训练过程中打包 .mar 文件
当我们决定在模型服务中使用 TorchServe 时,最好在训练时生成 .mar 文件。另外,因为 TorchServe 处理程序文件包含模型架构和模型执行逻辑,通常是模型训练代码的一部分。
打包 .mar 文件有两种方法。首先,在模型训练完成后,我们可以运行torch-model-archiver
CLI 工具,将模型权重打包成序列化文件,将依赖文件作为额外文件。其次,我们可以使用model_ archiver
Python 库,在模型训练代码的最后一步生成 .mar 文件。以下代码片段是我们用于打包意图分类模型的示例:
## Method one: package model by command line cli tool.
torch-model-archiver --model-name intent_classification --version 1.0 \
--model-file torchserve_model.py --serialized-file \
workspace/MiniAutoML/{model_id}/model.pth \
--handler torchserve_handler.py --extra-files \
workspace/MiniAutoML/{model_id}/vocab.pth,
➥ workspace/MiniAutoML/{model_id}/manifest.json
## Method two: package model in training code.
model_archiver.archive(model_name=archive_model_name,
handler_file=handler, model_state_file=model_local_path,
extra_files=extra_files, model_version=config.MODEL_SERVING_VERSION,
dest_path=config.JOB_ID)
7.2.7 在 Kubernetes 中扩展规模
在我们的示例服务中,为了演示目的,我们运行单个 TorchServe 容器作为预测后端,但这在生产环境中并非如此。扩展 TorchServe 面临的挑战如下:
-
负载均衡器使 TorchServe 模型注册变得困难。在 TorchServe 中,模型文件需要先注册到 TorchServe 服务器,然后才能使用。但是在生产环境中,TorchServe 实例被放置在网络负载均衡器后,因此我们只能将预测请求发送到负载均衡器,让它将请求路由到随机的 TorchServe 实例。在这种情况下,由于我们无法指定哪个 TorchServe 实例为哪个模型提供服务,注册模型变得困难。负载均衡器向我们隐藏了 TorchServe 实例。
-
每个 TorchServe 实例都需要有一个用于加载模型的模型存储目录,并且在可以注册之前,模型文件需要放在模型存储目录中。有多个 TorchServe 实例会使模型文件复制变得难以管理,因为我们需要知道每个 TorchServe 实例的 IP 地址或 DNS 来复制模型文件。
-
我们需要在 TorchServe 实例之间平衡模型。让每个 TorchServe 实例加载每个模型文件是一个糟糕的想法;这将极大浪费计算资源。我们应该将负载均匀分配到不同的 TorchServe 实例上。
要解决这些挑战并扩展 TorchServe 后端,我们可以在 Kubernetes 中引入“边车”模式。图 7.7 描绘了整体概念。
图 7.7 在 Kubernetes 中的 TorchServe pod 中添加代理容器以扩展 TorchServe。
图 7.7 中的建议是在每个 TorchServe pod 中与 TorchServe 容器一起添加代理容器(作为边车)。我们不直接调用 TorchServe API,而是将预测请求发送到代理容器。代理容器中的代理 API 将隐藏 TorchServe 模型管理细节,包括模型下载和模型注册。它将准备 TorchServe 容器以服务任意模型。
添加代理容器后,模型服务工作流程(图 7.7)如下。首先,预测请求落在代理容器上。其次,代理下载模型文件并将其输入到共享磁盘(模型存储库)。第三,代理将模型注册到 TorchServe 容器并将推理请求转换为 TorchServe 格式。第四,TorchServe 容器运行模型服务并将结果返回给代理。最后,代理容器将预测响应返回给用户。
通过添加代理容器,我们无需担心将预测请求发送到未注册该模型的 TorchServe 实例。代理容器(边车)将确保 TorchServe 容器准备好处理任何预测请求,方法是将模型文件复制到模型存储库并注册模型。这也简化了资源管理工作,因为现在我们可以简单地依赖负载均衡器将预测负载(模型)在 TorchServe pod 之间分配。此外,通过在所有 TorchServe pod 之间共享磁盘,我们可以为所有 TorchServe 实例共享模型存储库,从而减少了模型下载时间并节省了网络带宽。
边车模式:运行模型服务器的常用方法。
在第 7.4 节中,我们将介绍其他几种模型服务器方法,例如 TensorFlow serving 和 Triton。尽管这些模型服务器的实现方式不同,但它们的设计思想是相似的。它们都采用黑盒方法,并需要特定的模型格式和一些模型管理来启用模型服务。
图 7.7 中的旁路模式是在 Kubernetes pod 中运行这些不同模型服务器容器的常见解决方案。代理容器封装了模型服务器的所有特殊要求,并且只暴露通用模型服务 API。
7.3 模型服务器 vs. 模型服务
在设计模型服务应用程序时,选择模型服务器方法和模型服务方法之间是我们需要做出的第一个决定。当我们选择不当时,我们的服务应用程序要么难以使用和维护,要么构建所需时间过长。
我们已经在第六章(第 6.2 节和第 6.3 节)中回顾了这两种方法之间的差异,但这是一个非常关键的选择,值得再次审查。现在你已经看到了每种方法的具体示例,这些想法可能更容易理解。
通过在第 7.1 节和第 7.2 节中讨论的两个示例服务,可以清楚地看出,模型服务器方法避免了为特定模型类型构建专用后端预测器的工作量。相反,它可以直接使用,并且可以为任意模型提供服务,而不管模型实现了哪种算法。因此,模型服务器方法似乎应该始终是最佳选择。但这并不是真的;选择模型服务器或模型服务应该取决于用例和业务需求。
对于单一应用场景,在实践中,模型服务方法更容易构建和维护。模型服务后端预测器相当容易构建,因为模型服务代码是训练代码的简化版本。这意味着我们可以轻松地将模型训练容器转换为模型服务容器。一旦构建完成,模型服务方法更容易维护,因为我们完全拥有代码,并且工作流程简单。对于模型服务器方法,无论我们选择开源、预构建的模型服务器还是构建自己的服务器,设置系统的过程都很复杂。要学会并运营和维护系统需要花费大量的精力。
对于模型服务平台场景,系统需要支持多种不同类型的模型时,模型服务器方法无疑是最佳选择。当你为 500 种不同类型的模型构建模型服务系统时,如果选择模型服务器方法,你只需要一个单一类型的预测器后端来支持所有模型。相比之下,使用模型服务方法,你将需要 500 种不同的模型预测器!管理计算资源和进行所有这些预测器的维护工作非常困难。
我们的建议是,当你初学时使用模型服务方法,因为它更简单、更容易。当你需要在你的服务系统中支持超过 5 到 10 种类型的模型或应用时,你可以转向模型服务器方法。
7.4 开源模型服务工具巡回
有很多开源的模型服务工具可以使用。这些选择非常多,但是选择这么多有时会让人感到困惑。为了帮助您更轻松地做出选择,我们将向您介绍一些流行的模型服务工具,包括 TensorFlow Serving、TorchServe、Triton 和 KServe。所有这些工具都可以立即使用,并适用于生产用例。
因为这里描述的每一个工具都有详尽的文档,所以我们将保持讨论在一个通用的层面,只看它们的总体设计、主要特征和适当使用情况。这些信息应该足以作为一个起点,让你自己深入探索。
7.4.1 TensorFlow Serving。
TensorFlow Serving (www.tensorflow.org/tfx/guide/serving
) 是一个可自定义的独立网页系统,用于在生产环境中提供 TensorFlow 模型。TensorFlow Serving 采用模型服务器方法;它可以使用相同的服务器架构和 API 为所有类型的 TensorFlow 模型提供服务。
特征。
TensorFlow Serving 提供以下特点:
-
可以为多个模型或相同模型的多个版本提供服务。
-
与 TensorFlow 模型具有开箱即用的整合。
-
自动发现新的模型版本,支持不同的模型文件源。
-
具有统一的 gRPC 和 HTTP 端点,用于模型推断。
-
支持批量预测请求和性能调优。
-
具有可扩展的设计,可以在版本策略和模型加载上进行自定义。
高级架构。
在 TensorFlow Serving 中,一个模型由一个或多个可用服务程序组成。可用服务程序是用于执行计算的基础对象(例如查找或推断);它是 TensorFlow Serving 中的中心抽象。来源是用于查找和提供可用服务程序的插件模块。装载器标准是用于装载和卸载可用服务程序的 API。管理器处理可用服务程序的整个生命周期,包括加载、卸载和提供可用服务程序。
图 7.8 显示了 TensorFlow Serving 的架构和模型服务的生命周期。蓝色 = 最深的灰色;绿色 = 较浅的灰色;黄色 = 最浅的灰色。(来源:TensorFlow;mng.bz/KlNj
)
图 7.8 说明了向客户提供可提供服务的工作流程。首先,源插件为特定的可用服务程序创建一个加载器,加载器包含加载可用服务程序的元数据。第二,源从文件系统(模型库)中找到一个可用服务程序;它通知 DynamicManager 可用服务程序的版本和加载器。第三,基于预定义的版本策略,DynamicManager 确定是否加载模型。最后,客户端发送一个预测请求给可用服务程序,DynamicManager 返回一个句柄,以便客户端可以执行模型。
TensorFlow Serving 模型文件。
TensorFlow Serving 要求模型以 SavedModel (mng.bz/9197
) 格式保存。我们可以使用 tf.saved_model.save(model,
save_path)
API 来实现这个目的。一个保存的模型是一个包含了序列化签名和运行它们所需的状态的目录,包括变量值和词汇表。例如,一个保存的模型目录有两个子目录,assets
和 variables
,以及一个文件,saved_model.pb
。
assets 文件夹包含了 TensorFlow 图使用的文件,比如用于初始化词汇表的文本文件。variables 文件夹包含了训练检查点。saved_model.pb
文件存储了实际的 TensorFlow 程序,或者说模型,以及一组命名的签名,每个签名标识了一个接受张量输入并产生张量输出的函数。
模型服务
因为 TensorFlow 的 SavedModel 文件可以直接加载到 TensorFlow Serving 进程中,所以运行模型服务非常简单。一旦服务进程启动,我们可以将模型文件复制到 TensorFlow Serving 的模型目录中,然后立即发送 gRPC 或 REST 预测请求。让我们来看下面的预测示例:
# 1\. Save model in training code
MODEL_DIR='tf_model'
version = "1"
export_path = os.path.join(MODEL_DIR, str(version))
model.save(export_path, save_format="tf")
# 2\. Start tensorflow serving service locally as a docker container
docker run -p 8501:8501
--mount type=bind,source=/workspace/tf_model,target=/models/model_a/
-e MODEL_NAME=model_a -t tensorflow/serving
--model_config_file_poll_wait_seconds=60
--model_config_file=/models/model_a/models.config
# 3\. Send predict request to local tensorflow serving docker container
# The request url pattern to call a specific version of a model is
/v1/models/<model name>/versions/<version number>
json_response = requests.post('http:/ /localhost:8501/
➥ v1/models/model_a/versions/1:predict',
data=data, headers=headers)
为了将多个模型和同一模型的多个版本加载到服务服务器中,我们可以在模型配置中配置模型的版本,如下所示:
model_config_list {
config{
name: 'model_a'
base_path: '/models/model_a/'
model_platform: 'tensorflow'
model_version_policy{
specific{
versions:2 ❶
versions:3 ❷
}
}
}
config{
name: 'model_b'
base_path: '/models/model_b/'
model_platform: 'tensorflow'
}
}
❶ 在 /models/model_a/versions/2 找到模型 v2
❷ 在 /models/model_a/versions/3 找到模型 v3
在这个配置中,我们定义了两个模型,model_a
和 model_b
。因为 model_a
有一个 model_version_policy
,所以两个版本(v2 和 v3)都被加载并可以提供请求服务。默认情况下,模型的最新版本将被提供服务,所以当检测到 model_b
的新版本时,旧版本将被新版本替换。
回顾
TensorFlow Serving 是用于 TensorFlow 模型的生产级模型服务解决方案;它支持 REST、gRPC、GPU 加速、小批量处理和边缘设备上的模型服务。虽然 TensorFlow Serving 在高级指标、灵活的模型管理和部署策略方面存在不足,但如果你只有 TensorFlow 模型的话,它仍然是一个不错的选择。
TensorFlow Serving 的主要缺点是它是一个供应商锁定的解决方案;它只支持 TensorFlow 模型。如果你正在寻找一个训练框架无关的方法,那么 TensorFlow Serving 将不是你的选择。
7.4.2 TorchServe
TorchServe (pytorch.org/serve/
) 是一个性能出色、灵活且易于使用的工具,用于为 PyTorch eager 模式和 torchscripted 模型提供服务(torchscripted 模型是 PyTorch 模型的一种中间表示,可以在高性能环境(如 C++)中运行)。与 TensorFlow Serving 类似,TorchServe 采用模型服务器方法为所有类型的 PyTorch 模型提供服务,并提供统一的 API。不同之处在于 TorchServe 提供了一组管理 API,使模型管理非常方便灵活。例如,我们可以以编程方式注册和注销模型或模型的不同版本。我们还可以为模型和模型的不同版本扩展和缩小服务工作程序。
高层架构
一个 TorchServe 服务器由三个组件组成:前端、后端和模型存储。前端处理 TorchServe 的请求/响应。它还管理模型的生命周期。后端是一组负责在模型上运行实际推断的模型工作程序。模型存储是一个包含所有可加载模型的目录;它可以是云存储文件夹或本地主机文件夹。图 7.9 显示了 TorchServing 实例的高级架构。
图 7.9 TorchServe 架构图(来源:Kuldeep Singh,“使用 TorchServe 将命名实体识别模型部署到生产环境”,Analytics Vidhya,2020)
图 7.9 描绘了两个工作流程:模型推断和模型管理。对于模型推断,首先,用户将预测请求发送到模型的推断端点,例如 /predictions/{model_name}/{version}
。然后,推断请求被路由到已加载模型的工作进程之一。接下来,工作进程将从模型存储中读取模型文件,并让模型处理器加载模型,预处理输入数据,并运行模型以获得预测结果。
对于模型管理,用户需要在可以访问模型之前注册模型。这可以通过使用管理 API 来完成。我们还可以为模型调整工作进程计数的规模。我们将在即将到来的示例使用部分中看到一个示例。
功能
TorchServe 提供以下功能:
-
可以为多个模型或同一模型的多个版本提供服务
-
将模型推断的 gRPC 和 HTTP 端点统一起来
-
支持批量预测请求和性能调优
-
支持将 PyTorch 模型和 Python 函数组合成顺序和并行管道的工作流程
-
提供管理 API 来注册/注销模型和调整工作进程的规模
-
处理模型版本控制,用于 A/B 测试和实验
Torch 服务模型文件
纯 PyTorch 模型不能直接加载到 Torch 服务服务器中。TorchServe 要求将其所有模型打包成 .mar 文件。请参阅 7.2.6 节,了解如何创建 .mar 文件的详细示例。
模型服务
下面的代码片段列出了使用 TorchServe 运行模型推理的五个一般步骤。有关具体示例,您可以查看我们的示例意图分类预测器的 README 文档(mng.bz/WA8a
):
# 1\. Create model store directory for torch serving
# and copy model files (mar files) to it
mkdir -p /tmp/model_store/torchserving
cp sample_models/intent.mar /tmp/model_store/torchserving ❶
# 2\. Run the torch serving container
docker pull pytorch/torchserve:0.4.2-cpu
docker run --rm --shm-size=1g \
--ulimit memlock=-1 \
--ulimit stack=67108864 \
-p8080:8080 \
-p8081:8081 \
-p8082:8082 \
-p7070:7070 \
-p7071:7071 \
--mount type=bind,source=/tmp/model_store/torchserving,target=/tmp/models❷
pytorch/torchserve:0.4.2-cpu torchserve --model-store=/tmp/models
# 3\. Register intent model through torchserving management api
curl -X POST "http:/ /localhost:8081/models?url= ❸
➥ intent_1.mar&initial_workers=1&model_name=intent" ❸
# 4\. Query intent model in torch serving with default version.
curl --location --request GET 'http:/ /localhost:8080/predictions/intent' \
--header 'Content-Type: text/plain' \
--data-raw 'make a 10 minute timer'
# 5\. Query intent model in torch serving with specified version - 1.0
curl --location --request GET 'http:/ /localhost:8080/predictions/intent/1.0' \
--header 'Content-Type: text/plain' \
--data-raw 'make a 10 minute timer'
❶ 创建本地模型目录并复制意图分类模型
❷ 将本地模型目录绑定为 TorchServe 的模型存储目录
❸ Intent_1.mar 包含模型文件和模型元数据,例如模型版本。
使用管理 API 注册模型之外,我们还可以使用 scale worker API 动态调整任何版本模型的工作节点数量,以更好地服务不同的推理请求负载,如下例所示:
# 1\. Scale up the worker number for the intent model. Default number is 1.
# Set minimum worker count to 3 and maximum worker count to 6
# for version 1.0 of the intent model
curl -v -X PUT "http:/ /localhost:8081/models/intent/1.0?min_worker=3&max_worker=6"
# 2\. Use the describe model API to get detail runtime status of
# default version of the intent model.
curl http:/ /localhost:8081/models/intent
# 3\. Use the describe model API to get detail runtime status of
# specific version (1.0) of the intent model.
curl http:/ /localhost:8081/models/intent/1.0
回顾
TorchServe 是用于 PyTorch 模型的生产级模型服务解决方案;它专为高性能推理和生产用例而设计。TorchServe 的管理 API 增加了许多灵活性,用于自定义模型部署策略,并允许我们在每个模型级别管理计算资源。
与 TensorFlow Serving 类似,TorchServe 的主要缺点是它是一种供应商锁定解决方案;它仅支持 PyTorch 模型。因此,如果您正在寻找一种训练框架不可知的方法,TorchServe 将不是您的选择。
7.4.3 Triton 推理服务器
Triton 推理服务器(developer.nvidia.com/nvidia-triton-inference-server
)是由 NVIDIA 开发的开源推理服务器。它提供了一个针对 CPU 和 GPU 优化的云和边缘推理解决方案。Triton 支持 HTTP/REST 和 gRPC 协议,允许远程客户端请求服务器管理的任何模型的推理。对于边缘部署,Triton 可作为具有 C API 的共享库提供,允许直接在应用程序中包含 Triton 的全部功能。
与其他服务工具相比,Triton 的训练框架兼容性是其主要优势之一。与仅适用于 TensorFlow 模型的 TensorFlow Serving 和仅适用于 PyTorch 模型的 Torch 服务不同,Triton 服务器可以为从几乎任何框架训练的模型提供服务,包括 TensorFlow、TensorRT、PyTorch、ONNX 和 XGBoost。Triton 服务器可以从本地存储、Google Cloud Platform 或 Amazon Simple Storage Service (Amazon S3) 加载模型文件,并在基于 GPU 或 CPU 的基础设施(云、数据中心或边缘)上运行。
推理性能对于 Triton 也是一项优势。Triton 在 GPU 上并发运行模型,以最大化吞吐量和利用率;支持基于 x86 和 ARM CPU 的推断;并提供动态批处理、模型分析器、模型集成和音频流等功能。这些功能使模型服务内存高效且稳健。
高层架构
图 7.10 显示了 Triton 推理服务器的高级架构。所有推理请求都作为 REST 或 gRPC 请求发送,然后在内部转换为 C API 调用。模型从模型仓库加载,模型仓库是一个基于文件系统的仓库,我们可以将其视为文件夹/目录。
图 7.10 Triton 推理服务器的高级架构(来源:NVIDIA Developer,developer.nvidia.com/nvidia-triton-inference-server
)
对于每个模型,Triton 准备一个调度程序。调度和批处理算法可以根据模型逐个配置。每个模型的调度程序可选择对推理请求进行批处理,然后将请求传递给与模型类型对应的后端,例如 PyTorch 模型的 PyTorch 后端。 Triton 后端是执行模型的实现。它可以是围绕深度学习框架(如 PyTorch、TensorFlow、TensorRT 或 ONNX Runtime)的包装器。一旦后端使用批处理请求中提供的输入执行推理以产生请求的输出,输出就会返回。
值得注意的是,Triton 支持一个后端 C API,允许通过自定义预处理和后处理操作或甚至一个新的深度学习框架来扩展 Triton 的功能。这就是我们如何扩展 Triton 服务器的方式。您可以查看 triton-inference-server/backend GitHub 仓库(github.com/triton-inference-server/backend
)来找到所有 Triton 后端实现。作为一个额外的好处,由 Triton 服务的模型可以通过专用的模型管理 API 进行查询和控制,该 API 可通过 HTTP/REST、gRPC 协议或 C API 使用。
特点
Triton 提供以下功能:
-
支持所有主要的深度学习和机器学习框架后端。
-
在单个 GPU 或 CPU 上同时运行来自相同或不同框架的多个模型。在多 GPU 服务器上,Triton 会自动在每个 GPU 上创建每个模型的一个实例,以提高利用率。
-
优化推理服务以进行实时推理、批量推理以最大化 GPU/CPU 利用率,并使用内置支持音频流输入的流推理。Triton 还支持模型集成,用于需要多个模型执行端到端推理的用例,例如对话型 AI。
-
处理输入请求的动态批处理,以获得高吞吐量和利用率,并在严格的延迟约束下。
-
在生产环境中实时更新模型,而无需重新启动推理服务器或中断应用程序。
-
使用模型分析器自动找到最佳模型配置并最大化性能。
-
支持大模型的多 GPU、多节点推理。
Triton 模型文件
Triton 中的每个模型都必须包含一个模型配置,提供关于模型的必需和可选信息。通常,它是一个指定为 ModelConfig protobuf 的 config.pbtxt 文件(mng.bz/81Kz
)。以下是 PyTorch 模型的简单模型配置(config.pbtxt):
platform: “pytorch_libtorch” ❶
pytorch_libtorch ❷
max_batch_size: 8 ❸
input [ ❹
{
name: “input0”
data_type: TYPE_FP32
dims: [ 16 ]
},
{
name: “input1”
data_type: TYPE_FP32
dims: [ 16 ]
}
]
output [ ❺
{
name: “output0”
data_type: TYPE_FP32
dims: [ 16 ]
}
]
❶ 指定此模型的 PyTorch 服务后端
❷ 表示这是一个 PyTorch 后端配置
❸ 定义模型支持的最大批处理大小
❹ 模型输入数据架构
❺ 模型输出数据架构
通常,训练应用程序在训练服务完成训练后会创建此 config.pbtxt 文件,然后将此配置作为模型文件的一部分上传到模型存储库。有关 Triton 模型配置的更多详细信息,请查看 Triton 模型配置文档(mng.bz/Y6mA
)。
除了统一的配置文件外,Triton 模型文件格式因训练框架而异。例如,TensorFlow 模型以 SavedModel 格式(mng.bz/El4d
)可以直接加载到 Triton 中。但 PyTorch 模型需要由 TorchScript 程序保存。
TorchScript
TorchScript 是一种从 PyTorch 代码创建可序列化和可优化模型的方法。Triton 要求 PyTorch 模型被序列化为 TorchScript 的原因是 TorchScript 可以用作 PyTorch 模型的中间表示。它可以独立于 Python 运行,例如在独立的 C++ 程序中。请参阅以下代码片段,了解如何从 PyTorch 模型创建 TorchScript 模型:
#### Pytorch training code
# 1\. Define an instance of your model.
Model = ...TorchModel()
# 2\. Switch the model to eval model
model.eval()
# 3\. Build an example input of the model’s forward() method.
Example = torch.rand(1, 3, 224, 224)
# 4\. Use torch.jit.trace to generate a torch.jit.ScriptModule via tracing.
Traced_script_module = torch.jit.trace(model, example)
# 5\. Save the TorchScript model
traced_script_module.save(“traced_torch_model.pt”)
对于其他训练框架的模型格式要求,请查看 triton-inference-server/backend GitHub 存储库(mng.bz/NmOn
)。
模型服务
Triton 中的模型服务包括以下三个步骤:首先,将模型文件复制到模型存储库;其次,调用管理 API(POST v2/repository/ models/${MODEL_NAME}/load
)注册模型;第三,发送推理请求(POST v2/models/${MODEL_NAME}/versions/${MODEL_VERSION}
)。有关 Triton 管理 API 的更多信息,请查看 Triton HTTP/REST 和 gRPC 协议文档(mng.bz/DZvR
)。有关推理 API,请查看 KServe 社区标准推理协议文档(kserve.github.io/website/0.10/modelserving/data_plane/v2_protocol/
)。
回顾
在撰写本书时,我们认为 Triton 是最佳的模型服务方法,原因有三。首先,Triton 不依赖任何训练框架;它提供了一个设计良好且可扩展的后端框架,使得它能够执行几乎任何训练框架构建的模型。其次,Triton 提供了更好的模型服务性能,例如服务吞吐量。Triton 有多种机制来提高其服务性能,例如动态批处理、GPU 优化和模型分析工具。第三,Triton 支持高级的模型服务用例,如模型集成和音频流式传输。
警告 要小心! Triton 可能不是免费的。Triton 采用 BSD 3-Clause “new” 或 “revised” 许可证,可以免费进行修改和商业发布。但是修复故障和错误呢?该项目非常复杂,代码量大,因此会在调试和修复性能问题(如内存泄漏)上带来一定困难。如果你想获取支持,可以考虑购买 NVIDIA 的 AI-Enterprise 许可证,但在本书撰写时,这将需要支付每年每块 GPU 数千美元的费用。所以在注册之前,请确保你了解 Triton 的代码库。
7.4.4 KServe 和其他工具
开源服务工具的列表很长,包括 KServe (www.kubeflow.org/docs/external-add-ons/kserve/
)、Seldon Core (www.seldon.io/solutions/open-source-projects/core
) 和 BentoML (github.com/bentoml/BentoML
)。这些工具各有其独特的优势。它们要么运行轻量且易于使用,像 BentoML 一样,要么使得在 Kubernetes 中轻松快速部署模型,就像 Seldon Core 和 KServe 一样。尽管服务工具的多样性,它们也有很多共同之处:它们都需要以一定的格式打包模型,定义一个模型包装器和配置文件来执行模型,将模型上传到存储库,并通过 gRPC 或 HTTP/REST 端点发送预测请求。通过阅读本章中的 TorchServe、TensorFlow 和 Triton 示例,你应该能够自己探索其他工具。
在结束服务工具讨论之前,我们想特别提到 KServe。KServe 是几家知名高科技公司(包括 Seldon、Google、Bloomberg、NVIDIA、Microsoft 和 IBM)在模型服务方面的合作项目。这个开源项目值得关注,因为它旨在为常见的机器学习服务问题创建一个标准化的解决方案。
KServe 的目标是在 Kubernetes 上提供一种无服务器推断解决方案。它提供了一个抽象的模型服务接口,适用于像 TensorFlow、XGBoost、scikit-learn、PyTorch 和 ONNX 等常见的机器学习框架。
从我们的角度来看,KServe 的主要贡献在于它创建了一个标准的服务接口,适用于所有主要的服务工具。例如,我们之前提到的所有服务工具现在都支持 KServe 模型推理协议。这意味着我们可以仅使用一组推理 API(KServe API)来查询由不同服务工具托管的任何模型,如 Triton、TorchServe 和 TensorFlow。
KServe 的另一个优势是它被设计为在 Kubernetes 上本地提供无服务器解决方案。KServe 使用 Knative 来处理网络路由、模型工作器自动扩展(甚至到零)和模型版本跟踪。通过简单的配置(见下面的示例),您可以将模型部署到您的 Kubernetes 集群,然后使用标准化的 API 来查询它:
apiVersion: serving.kserve.io/v1beta1 ❶
kind: InferenceService
metadata:
name: “torchserve”
spec:
predictor:
pytorch: ❷
storageUri: gs://kfserving-examples/models ❸
➥ /torchserve/image_classifier ❸
❶ KServe 的样本模型部署配置
❷ 后端服务器类型
❸ 模型文件位置
在幕后,KServe 使用不同的服务工具来运行推理,如 TensorFlow Serving 和 Triton。KServe 提供了一个隐藏所有细节的简单 Kubernetes CRD 配置的好处。在前面的示例中,InferenceService
CRD 配置隐藏了工作,包括预测服务器设置、模型复制、模型版本跟踪和预测请求路由。
在书写本书时,KServe 的新版本(v2)仍处于测试阶段。虽然它还不够成熟,但其在跨平台支持和无服务器模型部署方面的独特优势使其在其他方法中脱颖而出。如果您想要建立一个适用于 Kubernetes 上所有主要训练框架的大型服务平台,那么 KServe 值得您的关注。
7.4.5 将服务工具集成到现有服务系统中
在许多情况下,用新的服务后端替换现有的预测服务是不可行的选择。每个服务工具对于模型存储、模型注册和推理请求格式都有自己的要求。这些要求有时与现有系统的预测接口以及内部模型元数据和文件系统相冲突。为了引入新技术而不影响业务,我们通常采取集成方法,而不是完全替换它。
在这里,我们以 Triton 服务器为例,展示如何将服务工具集成到现有的预测服务中。在这个示例中,我们假设三件事情:首先,现有的预测服务在 Kubernetes 中运行;其次,现有的预测服务的 Web 推理接口不允许更改;第三,有一个模型存储系统,将模型文件存储在云存储中,如 Amazon S3。图 7.11 显示了这个过程。
图 7.11 提议将一系列 Triton 服务器实例集成到现有服务系统中
图 7.11(A)说明了系统概述。一列 Triton 服务器 Kubernetes pod 被添加到现有的预测 API 后面。通过 Kubernetes 负载均衡器,预测请求可以落在任何 Triton pod 上。我们还添加了一个共享卷,所有 Triton pod 都可以访问;这个共享卷充当了所有 Triton 实例的共享 Triton 模型存储库。
图 7.11(B)显示了 Triton 服务器 Kubernetes pod 内部的内容。每个 Triton pod 都有两个 Docker 容器:一个 Triton 服务器容器和一个 sidecar 容器。Triton 服务器容器是我们在第 7.4.3 节中讨论的 Triton 推理服务器。模型预测发生在此容器中,我们可以简单地将此容器视为黑匣子。sidecar 容器充当适配器/代理,以准备 Triton 在将预测请求转发到 Triton 容器之前需要的内容。这个 sidecar 容器从云存储中下载模型到 Triton 的本地模型存储库(共享卷),调用 Triton 注册模型,并将预测请求转换为 Triton API 调用。
使用这种集成方法,所有的更改都发生在预测服务内部。公共预测 API 和外部模型存储系统保持不变,当我们切换到 Triton 后端时,我们的用户不会受到影响。虽然我们使用了一个特定的工具(Triton)和一个特定的基础设施(Kubernetes)来演示这个想法,但只要它们使用 Docker,你就可以将这种模式应用到任何其他系统中。
注意 因为 Triton 服务器支持主要的训练框架,KServe 提供了一个标准化的服务协议,我们可以将它们结合起来生成一个适用于不同框架训练的各种模型的服务系统。
7.5 发布模型
发布模型是将新训练的模型部署到预测服务并向用户公开的行为。在构建生产中的模型服务系统时,自动化模型部署和支持模型评估是我们需要解决的两个主要问题。
首先,当训练服务完成模型构建时,应自动将模型发布到生产环境中的预测服务。其次,新发布的模型及其先前版本应全部在预测服务中可访问,以便我们可以在相同环境中对它们进行评估并进行公平比较。在本节中,我们提出了一个三步模型发布过程来解决这些挑战。
首先,数据科学家(Alex)或训练服务向元数据存储注册最近生成的模型(由模型文件和其元数据组成)—这是一个将在下一章中讨论的云元数据和工件存储系统。其次,Alex 对新注册的模型进行模型评估。他可以通过向预测服务发送具有特定模型版本的预测请求来测试这些模型的性能。预测服务具有从元数据存储加载任何特定版本模型的内置机制。
第三,Alex 将性能最佳的模型版本设置为元数据存储中的发布模型版本。一旦设置完成,所选版本的模型将会公开!客户应用程序将不知不觉地开始使用来自预测服务的新发布版本的模型。图 7.12 说明了这个三步骤的过程。
图 7.12 模型发布流程工作流程:(1)在模型元数据存储中注册模型;(2)加载模型的任意版本以提供预测请求;以及(3)在元数据存储中发布模型
在接下来的三个部分中,我们将逐个探讨三个模型发布步骤(如图 7.12 所示)。在此过程中,我们还将探讨元数据存储的细节以及其与存储和预测服务的交互。让我们开始吧!
7.5.1 注册模型
在大多数深度学习系统中,都有一个存储模型的存储服务。在我们的示例中,这个服务称为元数据存储;它用于管理深度学习系统生成的工件的元数据,如模型。元数据和工件存储服务将在下一章中详细讨论。
要向元数据存储注册模型,通常需要提供模型文件和模型元数据。模型文件可以是模型权重、嵌入和执行模型所需的其他依赖文件。模型元数据可以是描述模型事实的任何数据,例如模型名称、模型 ID、模型版本、训练算法、数据集信息和训练执行指标。图 7.13 说明了元数据如何在内部存储模型元数据和模型文件。
图 7.13 元数据存储的内部存储设计;模型元数据存储为对象文件,并带有前置查找表。
在图 7.13 中,我们可以看到元数据存储有两个部分:模型查找表和模型元数据列表。模型元数据列表只是纯元数据存储;所有模型元数据对象都存储在这个列表中。模型查找表用作快速搜索的索引表。查找表中的每个记录指向元数据列表中的实际元数据对象。
在训练完成后,训练服务可以自动将模型注册到元数据存储中。数据科学家也可以手动注册模型,这通常发生在数据科学家想要部署他们本地构建的模型(而不使用深度学习系统)时。
当元数据存储接收到模型注册请求时,首先,它为此模型创建一个元数据对象。其次,通过添加一个新的搜索记录来更新模型查找表;该记录使我们能够通过使用模型名称和版本来找到该模型元数据对象。除了通过使用模型名称和版本搜索查找表外,元数据存储还允许通过使用模型 ID 进行模型元数据搜索。
实际模型文件存储在工件存储中——云对象存储,例如 Amazon S3。模型在工件存储中的存储位置保存在模型的元数据对象中作为指针。
图 7.13 显示了模型查找表中模型 A 的两个搜索记录:版本 1.0.0 和 1.1.0。每个搜索记录映射到不同的模型元数据对象(分别为 ID = 12345 和 ID = 12346)。有了这种存储结构,我们可以通过使用模型名称和模型版本找到任何模型元数据;例如,我们可以通过搜索“模型 A”和版本“1.1.0”找到模型元数据对象 ID = 12346。
使用模型的规范名称和版本来查找实际的元数据和模型文件是预测服务同时提供不同模型版本的能力的基础。让我们在下一节中看看元数据存储在预测服务中的应用。
- 实时加载模型的任意版本与预测服务
为了在生产环境中决定使用哪个模型版本,我们希望公平地(在相同的环境中)和轻松地(使用相同的 API)评估每个模型版本的模型性能。为此,我们可以调用预测服务以使用不同的模型版本运行预测请求。
在我们的提案中,当预测服务收到预测请求时,它会实时从元数据存储加载模型。数据科学家可以通过在预测请求中定义模型名称和版本来允许预测服务使用任何模型版本来运行预测。图 7.14 说明了该过程。
图 7.14 模型在预测服务中的服务
图 7.14 显示了预测服务实时加载指定的服务请求中的模型。在接收到预测请求时,路由层首先在元数据存储中找到请求的模型,下载模型文件,然后将请求传递给后端预测器。以下是运行时模型加载和服务过程的七个步骤的详细说明:
-
用户向预测服务发送预测请求。在请求中,他们可以通过提供模型名称和版本(
/predict/{model_name}/{version}
)或模型 ID(/predict/{model_id}
)来指定要使用的模型。 -
预测服务内部的路由层搜索元数据存储,并找到模型元数据对象。
-
路由层然后将模型文件下载到所有预测器都可以访问的共享磁盘上。
-
通过检查模型元数据,例如算法类型,路由层将预测请求路由到正确的后端预测器。
-
预测器从共享磁盘加载模型。
-
预测器处理数据预处理,执行模型,执行后处理,并将结果返回给路由层。
-
路由层将预测结果返回给调用者。
7.5.3 通过更新默认模型版本释放模型
在模型评估之后,模型释放的最后一步是让客户在预测服务中使用新验证的模型版本。我们希望模型释放过程在不知不觉中发生,以便客户不知道底层模型版本的更改。
在上一节(7.5.2)的步骤 1 中,用户可以使用/predict/{model_name}/{version}
API 请求任何指定模型版本的模型服务。这种能力对于评估同一模型的多个版本至关重要,因此我们可以防止模型性能回归。
但在生产场景中,我们不希望客户跟踪模型版本和模型 ID。相反,我们可以定义几个静态版本字符串作为变量来表示新发布的模型,并让客户在预测请求中使用它们,而不是使用真实的模型版本。
例如,我们可以定义两个特殊的静态模型版本或标签,例如STG
和PROD
,分别表示预生产和生产环境。如果与模型 A 关联的PROD
标签的模型版本为1.0.0
,则用户可以调用/predict/model_A/PROD
,而预测服务将加载模型 A 和版本1.0.0
来运行模型服务。当我们将新发布的模型版本升级到1.2.0
时——将PROD
标签与版本 1.2.0 关联——/predict/model_A/PROD
请求将落在模型版本1.2.0
上。
有了特殊的静态版本/标签字符串,预测用户不需要记住模型 ID 或版本;他们只需使用/predict/{model_name}/PROD
即可发送预测请求以消耗新发布的模型。在幕后,我们(数据科学家或工程师)维护这些特殊字符串与元数据存储的查找表中实际版本之间的映射,因此预测服务知道对于/STG
或/PROD
请求下载哪个模型版本。
在我们的提案中,我们将将特定模型版本映射到静态模型版本的操作命名为模型释放操作。图 7.15 说明了模型释放过程。
图 7.15 在带有元数据存储的预测服务中模型提供服务
在图 7.15 中,数据科学家首先在元数据存储中将模型 A,版本 1.0.0 注册到模型 A,版本PROD
。然后在模型查找表中,(Model
A,
PROD)
记录更改为指向实际的模型对象记录(ModelA,
version:
1.0.0)
。因此,当用户在预测服务中调用/predict/ModelA/PROD
时,他们实际上是在调用/predict/ModelA/1.0.0
。
接下来,当预测服务收到一个模型版本等于STG
或PROD
的预测请求时,服务将在元数据存储中搜索查找表,并使用实际的模型版本,即已注册到PROD
的版本,来下载模型文件。在图 7.15 中,预测服务将为/ModelA/PROD
的请求加载模型ModelA, version:
1.0.0,并为/ModelA/STG
的请求加载模型ModelA, version:
1.1.0。
对于未来的模型发布,数据科学家只需在元数据存储的查找表中更新模型记录,将最新的模型版本映射到STG
和PROD
。预测服务将自动加载新的模型版本以响应新的预测请求。所有这些操作都是自动进行的,对用户来说是不可感知的。
注意:所提出的发布工作流程并不是发布模型的唯一方法。模型发布方法高度依赖于公司内部的 DevOps 流程和预测服务设计,因此在这个主题上没有单一的最佳设计。我们希望通过阅读第 7.5 节中的问题分析和提出的解决方案,您可以得出适合您情况的模型发布流程。
7.6 生产后模型监控
与监控其他服务(如数据管理)相比,在机器学习系统中,模型投入生产后工作仍未完成。我们不仅需要监控和维护预测服务本身,还需要关注服务提供的模型性能。模型漂移是指知识领域分布发生变化,不再与训练数据集匹配,导致模型性能下降。这可能发生在预测服务完全正常运行的情况下,因为模型推理是独立于预测服务运行的。
为了应对模型漂移,数据科学家需要使用新数据重新训练模型或使用改进的训练算法重建模型。表面上,这听起来像是一个数据科学项目,但它需要大量的底层工程工作,例如从预测服务收集和分析模型指标以检测模型漂移。在本节中,我们从工程的角度讨论模型监控,并探讨工程师在监控过程中的作用。
7.6.1 指标收集和质量门限
工程师可以为 模型指标收集 和 模型质量门设置 这两个最重要的领域做出贡献。让我们解释一下。
为了运行检测模型漂移的分析,数据科学家需要分析数据,工程师可以找到途径提供必要的数据(指标)。尽管工程师可能必须创建一个单独的数据管道来收集模型性能指标,但在大多数情况下这将是不划算的。通常,模型性能指标可以使用现有的遥测系统(如 Datadog)和日志记录系统(如 Sumo 和 Splunk)进行收集和可视化。因此,请善用您已经拥有的现有日志记录和指标系统,而不是费力地建立一个新的指标系统。
工程师还可以帮助构建模型质量门。工程师可以与数据科学家合作,自动化他们的故障排除步骤,例如检查数据质量和生成模型推理分析报告。通过给定的阈值,这些检查最终将形成一个模型质量门。
7.6.2 需要收集的指标
从理论上讲,我们需要收集至少五种指标来支持模型性能测量。它们是预测跟踪、预测日期、模型版本、观察和观察率、以及日期。让我们逐一来看看它们:
-
预测跟踪 — 通常,我们通过为每个预测请求分配一个唯一的请求 ID 来跟踪每个预测请求,但这还不够。对于一些复杂的场景,比如 PDF 扫描,我们将不同类型的模型预测组合在一起以产生最终结果。例如,我们首先将 PDF 文档发送到 OCR(光学字符识别)模型以提取文本信息,然后将文本发送到 NLP(自然语言处理)模型以识别目标实体。在这种情况下,除了为父预测请求分配唯一的请求 ID 外,我们还可以为每个子预测请求分配一个
groupRequestID
,这样我们就可以在故障排除时将所有相关的预测请求分组起来。 -
预测日期 — 通常,预测请求在一秒内完成。为了追踪预测的日期,我们可以使用预测开始时间或完成时间,因为它们之间没有太大的区别。但是对于像欺诈检测这样的情况,预测的完成时间戳可能与预测开始时间戳相差很大,因为它可能需要多天的用户活动作为输入。
-
模型版本 — 为了将模型性能数据映射到确切的模型文件,我们需要知道模型版本。此外,当我们组合多个模型来提供一个预测请求时,需要在日志中跟踪每个模型的版本。
-
观察 —— 预测结果需要与预测输入一起记录,以供将来比较。此外,我们可以为客户提供反馈或调查 API,以报告模型性能问题。通过使用反馈 API,客户可以报告模型 ID、预期预测结果和当前预测结果。
-
观察日期和频率 —— 许多时候,观察是手动收集的,观察的频率也需要记录。数据科学家需要日期和频率来决定数据是否能够在统计上代表模型的整体性能。
你能读到这里真是太好了!模型服务是机器学习系统的重要组成部分,因为外部业务应用程序依赖于它。随着模型类型、预测请求数量和推理类型(在线/离线)的增加,许多模型服务框架/系统被发明出来,并且它们变得越来越复杂。如果你遵循第 6 和第七章介绍的服务心理模型,从模型如何加载和执行开始,你可以轻松地浏览这些服务系统,无论代码库有多大或组件数量有多少。
摘要
-
这一章的模型服务示例由一个前端 API 组件和一个后端模型预测器容器组成。由于预测器是基于第三章的意图模型训练代码构建的,因此它只能为意图分类模型提供服务。
-
模型服务器示例由与第三章相同的前端 API 和一个不同的后端 —— TorchServe 预测器组成。TorchServe 后端不仅限于意图分类模型;它可以为任意的 PyTorch 模型提供服务。这是模型服务器方法在模型服务方法上的一个巨大优势。
-
对于实施模型服务器方法,我们建议使用现有工具,例如 Triton 服务器,而不是自己构建。
-
模型服务方法适用于单个应用场景;它可以快速实施,并且您可以完全控制端到端工作流的代码实现。
-
模型服务器方法适用于平台场景;当服务系统需要支持五种或更多不同类型的模型时,它可以极大地减少开发和维护工作量。
-
TorchServe、TensorFlow Serving 和 Triton 都是可靠的开源模型服务工具,它们都采用了模型服务器方法。如果适用,我们推荐 Triton,因为它与大多数模型训练框架兼容,并且在 GPU 加速方面具有性能优势。
-
KServe 提供了一个标准的服务接口,适用于所有主要的服务工具,包括 TensorFlow Serving、TorchServe 和 Triton。KServe 可以极大地提高我们的服务系统的兼容性,因为我们可以使用单一集合的 API 来运行具有不同后端的模型服务。
-
在生产中发布新模型或模型服务系统的新版本不应该是事后才考虑的事情;我们需要在设计阶段妥善考虑这一点。
-
模型度量收集和模型质量门是工程师需要专注于模型性能监控的两个领域。