机器学习中模型训练的任务并不仅仅是研究人员和数据科学家的独有责任。是的,他们在训练算法方面的工作至关重要,因为他们定义了模型架构和训练计划。但就像物理学家需要一个软件系统来控制电子-正电子对撞机来测试他们的粒子理论一样,数据科学家需要一个有效的软件系统来管理昂贵的计算资源,如GPU、CPU和内存,以执行训练代码。这个管理计算资源并执行训练代码的系统被称为模型训练服务。
构建高质量的模型不仅依赖于训练算法,还依赖于计算资源和执行训练的系统。一个好的训练服务可以使模型训练速度更快、更可靠,还可以降低平均模型构建成本。当数据集或模型架构非常庞大时,使用训练服务来管理分布式计算是你唯一的选择。
在本章中,我们首先探讨了训练服务的价值主张和设计原则,然后介绍了我们的示例训练服务。这个示例服务不仅向你展示了如何实践应用设计原则,还教你训练服务如何与任意训练代码进行交互。接下来,我们介绍了几个开源训练应用程序,你可以使用它们快速搭建自己的训练服务。最后,我们讨论了何时使用公共云训练系统。
模型训练的话题常常让工程师感到害怕。一个常见的误解是,模型训练只涉及训练算法和研究。通过阅读本章,我希望你不仅学会如何设计和构建训练服务,还能吸收到这个信息:模型训练的成功建立在两个支柱上,即算法和系统工程。组织中的模型训练活动如果没有良好的训练系统,将无法扩展。因此,作为软件工程师,我们在这个领域有很多贡献要做。
在企业环境中,深度学习模型训练涉及两个角色:数据科学家负责开发模型训练算法(使用TensorFlow、PyTorch或其他框架),平台工程师负责构建和维护在远程共享服务器群中运行模型训练代码的系统。我们将这个系统称为模型训练服务。
模型训练服务作为一个训练基础设施,在专用环境中执行模型训练代码(算法),同时处理训练作业调度和计算资源管理。图3.1显示了一个高级工作流程,模型训练服务运行模型训练代码生成模型。
关于这个组件最常见的问题是为什么我们需要编写一个服务来进行模型训练。对于很多人来说,编写一个简单的bash脚本来执行训练代码(算法),无论是在本地还是远程,比如使用AmazonElasticCloudComputing(AmazonEC2)实例,似乎更容易。然而,构建训练服务的原理不仅仅是启动训练计算。我们将在下一节详细讨论这一点。
第一种选项是独立分配,即为团队的每个成员专门分配一台强大的工作站。这是最简单的方法,但显然不经济,因为当Alex不运行他的训练代码时,他的服务器处于闲置状态,Bob和Kevin也无法使用它。因此,在这种方法中,我们的预算被低效利用。
独立分配的另一个问题是它无法扩展。当Alex想要训练一个大模型或具有大型数据集的模型时,他将需要多台机器。而训练机器通常是昂贵的;由于深度学习模型架构的复杂性,即使是一个相当大的神经网络也需要具有大内存的GPU。在这种情况下,我们必须给Alex分配更多的专用服务器,这加剧了资源分配效率低下的问题。
第二个选项是共享计算资源,即构建一个弹性的服务器组(组的大小可调整),并与所有成员共享。这种方法显然更经济,因为我们使用较少的服务器来实现相同的结果,从而最大化资源利用率。
选择共享策略并不是一个困难的决定,因为它大大降低了我们训练集群的成本。但共享方法需要适当的管理,例如在出现突发的训练请求时排队用户请求,对每个训练执行进行监控,并在必要时进行干预(重新启动或中止)(训练进度卡住),并根据实时系统使用情况扩展或缩小我们的集群。
脚本VS.服务
现在让我们重新审视之前关于脚本与服务的讨论。在模型训练的上下文中,训练脚本指的是使用Shell脚本在共享服务器集群中编排不同的训练活动。训练服务是一个通过网络使用HTTP(超文本传输协议)或gRPC(gRPC远程过程调用)进行通信的远程进程。作为数据科学家,Alex和Bob向服务发送训练请求,而服务则编排这些请求,并在共享服务器上管理训练执行。
脚本方法在单人场景下可能有效,但在共享资源环境中会变得困难。除了执行训练代码,我们还需要处理其他重要的元素,如设置环境、确保数据合规性和解决模型性能问题。例如,环境设置要求在启动模型训练之前,正确安装训练框架和训练代码的库依赖项。数据合规性要求对敏感的训练数据(用户信用卡号、支付记录)进行受限访问的保护。性能故障排除要求跟踪训练中使用的所有内容,包括数据集ID和版本、训练代码版本和超参数,以便进行模型重现。
很难想象通过Shell脚本来满足这些要求,并以可靠、可重复和可扩展的方式执行模型训练。这就是为什么现在大多数在生产中训练的模型都是由经过深思熟虑设计的模型训练服务生成的。
拥有模型训练服务的好处
根据之前的讨论,我们对模型训练服务的价值主张的想象,模型训练服务能够带来这些好处:
在我们查看示例训练服务之前,让我们先了解四个设计原则,以评估模型训练系统。
原则1:提供统一的API,对实际的训练代码保持独立
训练服务只需要一个公共API来训练不同类型的模型训练算法,这样可以使训练服务易于使用。无论是目标检测训练、语音识别训练还是文本意图分类训练,我们可以使用示例API来触发模型训练的执行。通过只有一个训练API,未来的算法性能A/B测试也可以很容易地实现。
独立于训练代码意味着训练服务定义了一个明确的机制或协议来执行训练算法(代码)。例如,它规定了服务如何传递变量给训练代码/进程,训练代码如何获取训练数据集,以及训练后的模型和指标如何上传。只要训练代码遵循这个协议,它的实现方式、模型架构或使用的训练库都不重要。
原则2:构建高性能、低成本的模型
原则3:支持模型可重现性
一个服务应该在给定相同输入的情况下生成相同的模型。这不仅对于调试和性能故障排除很重要,而且可以建立对系统的可信度。记住,我们将基于模型的预测结果构建业务逻辑。例如,我们可能会使用分类模型来预测用户的信用,并根据此做出贷款批准决策。除非我们能够重复产生相同质量的模型,否则我们不能完全信任整个贷款批准申请。
原则4:支持强大、隔离和弹性的计算资源管理
在讨论了所有重要的抽象概念之后,让我们来设计和构建一个模型训练服务。在接下来的两节中,我们将学习深度学习代码的一般模式以及一个模型训练服务的示例。
对于工程师来说,深度学习算法可能很复杂,常常让人望而生畏。幸运的是,作为设计深度学习系统平台的软件工程师,我们在日常工作中不需要精通这些算法。然而,我们需要熟悉这些算法的一般代码模式。通过对模型训练代码模式有一个高层次的理解,我们可以将模型训练代码视为一个黑盒。在本节中,我们将向您介绍一般模式。
简而言之,大多数深度学习模型通过迭代学习过程进行训练。该过程在许多迭代中重复相同的计算步骤,每次迭代都尝试更新神经网络的权重和偏差,使算法的输出(预测结果)更接近数据集中的训练目标。
为了衡量神经网络对给定数据的建模能力,并利用它更新神经网络的权重以获得更好的结果,定义了一个损失函数来计算算法输出与实际结果之间的偏差。损失函数的输出被命名为LOSS。
因此,您可以将整个迭代训练过程视为不断努力降低损失值。最终,当损失值达到我们的训练目标或无法进一步降低时,训练完成。训练的输出是神经网络及其权重,但通常我们简称为模型。
图3.3展示了一般的模型训练步骤。由于神经网络由于内存限制无法一次加载整个数据集,通常在训练开始之前将数据集重新分组成小批量(mini-batches)。在第1步中,将小批量样本输入神经网络,网络计算出每个样本的预测结果。在第2步中,将预测结果和期望值(训练标签)传递给损失函数,计算损失值,该值表示当前学习和目标数据模式之间的偏差。在第3步中,一个称为反向传播的过程计算出损失值对于神经网络各个参数的梯度。这些梯度用于更新模型参数,使得模型可以在下一次训练循环中获得更好的预测准确性。在第4步中,使用选择的优化算法(如随机梯度下降及其变种)更新神经网络的参数(权重和偏差)。梯度(来自第3步)和学习率是优化算法的输入参数。在这个模型更新步骤之后,模型的准确性应该会有所提高。最后,在第5步中,训练完成,网络及其参数被保存为最终的模型文件。训练在以下两个条件之一下完成:完成预期的训练次数或达到预期的模型准确性。
虽然有不同类型的模型架构,包括循环神经网络(RNN)、卷积神经网络(CNN)和自编码器,但它们的模型训练过程都遵循相同的模式;只是模型网络不同。此外,将模型训练代码抽象为之前重复的一般步骤是运行分布式训练的基础。这是因为无论模型架构如何不同,我们都可以以共同的训练策略对其进行训练。我们将在下一章详细讨论分布式训练。
在之前讨论的训练模式的基础上,我们可以将深度学习训练代码视为一个黑盒。无论训练代码实现了什么样的模型架构和训练算法,我们可以在训练服务中以相同的方式执行它。为了在训练集群的任何位置运行训练代码并为每个训练执行创建隔离,我们可以将训练代码和其依赖的库打包到一个Docker镜像中,并将其作为容器运行(图3.4)。
在图3.4中,通过将训练代码Docker化,训练服务可以通过简单地启动一个Docker容器来执行模型训练。由于服务对容器内部的内容不关心,训练服务可以以这种标准方法执行所有不同的代码。这比让训练服务生成一个进程来执行模型训练要简单得多,因为训练服务需要为每个训练代码设置各种环境和依赖包。Docker化的另一个好处是解耦了训练服务和训练代码,使得数据科学家和平台工程师可以分别专注于模型算法开发和训练执行性能。
你可能想知道如果训练服务和训练代码彼此之间不知道对方的情况下,训练服务如何与训练代码进行通信。关键是定义一种通信协议;这个协议描述了训练服务向训练代码传递哪些参数及其数据格式。这些参数包括数据集、超参数、模型保存位置、指标保存位置等。我们将在下一节中看到一个具体的示例。
正如我们现在所了解的,大多数深度学习训练代码都遵循相同的模式(图3.3),并且可以以统一的方式进行Docker化和执行(图3.4)。让我们更详细地看一个具体的示例。
为了演示我们之前介绍的概念和设计原则,我们构建了一个示例服务,实现了模型训练的基本生产场景,包括接收训练请求、在Docker容器中启动训练执行,并跟踪其执行进度。虽然这些场景非常简单,只有几百行代码,但它们展示了我们在前面几节中讨论的关键概念,包括使用统一的API、Docker化的训练代码以及训练服务与训练容器之间的通信协议。
注意:为了清晰地展示关键部分,该服务以简洁的方式构建。模型训练元数据(如正在运行的作业和等待的作业)在内存中进行跟踪,而不是在数据库中,并且训练作业直接在本地的Docker引擎中执行。通过去除许多中间层,您将直接了解到两个关键领域:训练作业管理和训练服务与训练代码(Docker容器)之间的通信。
在我们查看服务的设计和实现之前,让我们看看如何与其进行互动。
注意:请按照GitHub上的说明来运行这个实验。我们只强调运行示例服务的主要步骤和关键命令,以避免冗长的代码和执行输出,以便清晰地展示概念。要运行此实验,请按照orca3/MiniAutoMLGit存储库中的“singletrainerdemo”文档(training-service/single_trainer_demo.md)中的说明操作,该文档还记录了所需的输出。
首先,我们使用scripts/ts-001-start-server.sh启动服务:
dockerbuild-torca3/services:latest-fservices.dockerfile.dockerrun--nametraining-service-v/var/run/docker.sock:/var/run/docker.sock--networkorca3--rm-d-p"${TS_PORT}":51001orca3/services:latesttraining-service.jar在启动训练服务的Docker容器之后,我们可以发送一个gRPC请求来启动模型训练执行(scripts/ts-002-start-run.sh)。以下是一个示例gRPC请求。
grpcurl-plaintext-d"{"metadata":{"algorithm":"intent-classification","dataset_id":"1","name":"test1","train_data_version_hash":"hashBA==","parameters":{"LR":"4","EPOCHS":"15","BATCH_SIZE":"64","FC_SIZE":"128"}}}""${TS_SERVER}":"${TS_PORT}"training.TrainingService/Train一旦作业成功提交,我们可以使用从训练API返回的作业ID来查询训练执行的进度(scripts/ts-003-check-run.sh);以下是一个示例:
grpcurl-plaintext\-d"{"job_id\":"$job_id"}"\"${TS_SERVER}":"${TS_PORT}"training.TrainingService/GetTrainingStatus正如您所见,通过调用两个gRPCAPI,我们可以启动深度学习训练并跟踪其进度。现在,让我们来看看这个示例训练服务的设计和实现。
注意:如果遇到任何问题,请查看附录A。附录A中的脚本可以自动完成数据集的准备和模型训练。如果您想看到一个可工作的模型训练示例,请阅读该部分的实验部分。
让我们以Alex(一位数据科学家)和Tang(一位开发人员)为例,展示该服务的功能。要使用训练服务来训练模型,Alex需要编写训练代码(例如神经网络算法)并将代码构建为一个Docker镜像。这个Docker镜像需要发布到一个构件存储库,这样训练服务就可以拉取该镜像并将其作为容器运行。在Docker容器内部,训练代码将由一个bash脚本执行。
注意:在实际场景中,训练Docker镜像的创建、发布和使用是自动完成的。一个示例场景可能如下:步骤1,Alex将训练代码提交到Git仓库;步骤2,预配置的程序(例如Jenkins流水线)触发从该仓库构建Docker镜像;步骤3,流水线还将Docker镜像发布到Docker镜像仓库,例如JFrogArtifactory;步骤4,Alex发送一个训练请求,然后训练服务从镜像仓库拉取训练镜像并开始模型训练。
当Alex完成训练代码的开发后,他可以开始使用服务来运行自己的训练代码。整个工作流程如下:步骤1.a,Alex向我们的示例训练服务提交一个训练请求。该请求定义了训练代码,即一个Docker镜像和标签。当训练服务收到训练请求时,它在队列中创建一个作业,并将作业ID返回给Alex以便将来跟踪作业;步骤1.b,Alex可以查询训练服务以实时获取训练进度;步骤2,服务在本地Docker引擎中以Docker容器的形式启动一个训练作业来执行模型训练;步骤3,在训练过程中,Docker容器中的训练代码将训练指标存储到元数据存储中,并在训练完成时存储最终的模型。
注意:我们之前提到的模型评估是模型训练工作流中没有提及的一个步骤。在模型训练完成后,Alex(数据科学家)将查看由训练服务报告的训练指标以验证模型的质量。为了评估模型质量,Alex可以检查预测失败率、梯度和损失值图表。由于模型评估通常是数据科学家的责任,我们不会在本书中涵盖此内容,但我们将在第8章中讨论如何收集和存储模型训练指标。
在了解了用户工作流程之后,让我们来看一下两个关键组件:内存存储器和Docker作业跟踪器。内存存储器使用以下四个数据结构(映射)来组织请求(作业):作业队列、作业启动列表、运行列表和完成列表。每个映射表示不同运行状态的作业。我们仅在内存中实现作业跟踪存储器是为了简单起见;理想情况下,我们应该使用数据库。
Docker作业跟踪器负责在Docker引擎中处理实际的作业执行;它定期监视内存存储器中的作业队列。当Docker引擎有空闲容量时,跟踪器将从作业队列中启动一个Docker容器,并持续监视容器的执行情况。在我们的示例中,我们使用本地的Docker引擎,所以服务可以在您的本地运行。但是也可以很容易地配置为远程的Docker引擎。
在启动训练容器之后,根据执行状态,Docker作业跟踪器将作业对象从作业队列移动到其他作业列表,例如作业启动列表、运行列表和完成列表。在第3.4.4节中,我们将详细讨论这个过程。
注意:考虑到数据集将在训练容器中进行拆分(在训练时)。在数据集构建或模型训练过程中拆分数据集是有效的,这两个过程都有优缺点。但无论哪种方式,都不会对训练服务的设计产生重大影响。为简单起见,在这个示例训练服务中,我们假设算法代码将数据集拆分为训练、验证和测试子集。
在了解了概述之后,让我们深入了解公共gRPCAPI(grpc-contract/src/main/proto/training_service.proto),以更深入地了解该服务。训练服务中有两个API:Train和GetTrainingStatus。TrainAPI用于提交训练请求,而GetTrainingStatusAPI用于获取训练执行状态。请参见以下清单中的API定义。
一旦TrainAPI接收到训练请求,服务将将请求放入作业队列,并返回一个ID(job_id),供调用者引用该作业。可以使用job_id与GetTrainingStatusAPI一起检查训练状态。现在我们已经看到了API的定义,让我们在接下来的两节中看一下它们的实现。
当用户调用TrainAPI时,一个训练请求将被添加到内存存储的作业队列中,然后Docker作业跟踪器在另一个线程中处理实际的作业执行。这个逻辑将在接下来的三个清单(3.3-3.5)中进行解释。
接收训练请求
首先,一个新的训练请求将被添加到作业等待队列中,并被分配一个作业ID以便以后引用;请参见以下代码(training-service/src/main/java/org/orca3/miniAutoML/training/TrainingService.java)。
一旦作业进入等待队列,当本地Docker引擎有足够的系统资源时,Docker作业跟踪器将处理该作业。图3.6显示了整个过程。Docker作业跟踪器监视作业等待队列,并在本地Docker引擎有足够容量时选择第一个可用的作业(图3.6中的步骤1)。然后,Docker作业跟踪器通过启动Docker容器执行模型训练作业(步骤2)。容器成功启动后,跟踪器将作业对象从作业队列移动到启动列表队列(步骤3)。图3.6的代码实现(training-service/src/main/java/org/orca3/miniAutoML/training/tracker/DockerTracker.java)在清单3.4中突出显示。
跟踪训练进度
在最后一步中,Docker作业跟踪器通过监控容器的执行状态继续跟踪每个作业。当它检测到容器状态的变化时,作业跟踪器将容器的作业对象移动到内存存储中的相应作业列表中。
作业跟踪器将查询Docker运行时以获取容器的状态。例如,如果作业的Docker容器开始运行,作业跟踪器将检测到变化并将作业放入“正在运行的作业列表”;如果作业的Docker容器完成,作业跟踪器将将作业移动到“已完成的作业列表”。一旦作业被放置在“已完成的作业列表”上,作业跟踪器将停止检查作业状态,这意味着训练已经完成。图3.7描述了这个作业跟踪工作流程。代码清单3.5突出显示了这个作业跟踪过程的实现。
根据图3.8,我们可以看到获取训练状态只需要一步,即步骤1.b。此外,可以通过查找哪个作业列表(在内存存储中)包含jobId来确定训练作业的最新状态。请参考以下代码来查询训练作业/请求的状态(training-service/src/main/java/org/orca3/miniAutoML/training/TrainingService.java)。
publicvoidgetTrainingStatus(GetTrainingStatusRequestrequest){intjobId=request.getJobId();......if(store.finalizedJobs.containsKey(jobId)){job=store.finalizedJobs.get(jobId);status=job.isSuccess()TrainingStatus.succeed:TrainingStatus.failure;}elseif(store.launchingList.containsKey(jobId)){job=store.launchingList.get(jobId);status=TrainingStatus.launch;}elseif(store.runningList.containsKey(jobId)){job=store.runningList.get(jobId);status=TrainingStatus.running;}else{TrainingJobMetadatametadata=store.jobQueue.get(jobId);status=TrainingStatus.waiting;......}......}由于Docker作业跟踪器实时将作业移动到相应的作业列表中,我们可以依赖于使用作业队列类型来确定训练作业的状态。
到目前为止,我们一直在处理训练服务代码。现在让我们来看一下最后一个部分,即模型训练代码。请不要被这里的深度学习算法所吓倒。这个代码示例的目的是向您展示训练服务如何与模型训练代码进行交互的具体示例。图3.9展示了示例意图分类训练代码的工作流程。
我们的示例训练代码训练一个三层神经网络进行意图分类。它首先从通过我们的训练服务传递的环境变量中获取所有的输入参数(参见第3.3.4节)。输入参数包括超参数(epoch数量、学习率等)、数据集下载设置(MinIO服务器地址、数据集ID、版本哈希)和模型上传设置。接下来,训练代码下载和解析数据集,并开始迭代的学习过程。在最后一步,代码将生成的模型和训练指标上传到元数据存储中。以下代码清单突出了前面提到的主要步骤(train-service/text-classification/train.py和train-service/text-classification/Dockerfile)。
在3.1.2节中,我们提到一个好的训练服务应该解决计算隔离并提供按需计算资源(原则4)。这种隔离有两个意义:训练过程执行的隔离和资源消耗的隔离。由于我们将训练过程Docker化,进程执行的隔离由Docker引擎保证。但是,我们仍然需要自己处理资源消耗的隔离问题。
为了解决这个资源竞争问题,我们需要在训练集群中为不同团队和用户设置边界。我们可以在训练集群中创建机器池,以创建资源消耗隔离。可以将每个团队或用户分配给一个专用的机器池,每个池子都有自己的GPU和机器,池子的大小取决于项目需求和训练使用情况。此外,每个机器池可以有一个专用的作业队列,这样重度用户就不会影响其他用户。图3.9展示了这种方法的工作原理。注意资源分离方法(例如我们刚刚提到的服务器池方法)在资源利用方面可能不高效。例如,服务器池A可能非常繁忙,而服务器池B可能处于空闲状态。可以定义每个服务器池的大小范围,而不是一个固定的数字,例如最少5台服务器和最多10台服务器,以提高资源利用率。然后可以应用额外的逻辑,将服务器在池之间进行移动或提供新的服务器。实现图3.10的理想方法是使用Kubernetes。Kubernetes允许您创建由相同物理集群支持的多个虚拟集群,这称为命名空间。Kubernetes命名空间是一个消耗非常少系统资源的轻量级机器池。
如果您正在使用Kubernetes来管理您的服务环境和计算集群,那么设置这样的隔离非常简单。首先,您可以创建一个带有资源配额的命名空间,例如CPU数量、内存大小和GPU数量;然后,在训练服务中定义用户及其命名空间的映射关系。
现在,当用户提交训练请求时,训练服务首先通过检查请求中的用户信息找到合适的命名空间,然后调用KubernetesAPI将训练可执行文件放置在该命名空间中。由于Kubernetes实时跟踪系统使用情况,它知道一个命名空间是否有足够的容量,如果命名空间已满,它将拒绝作业启动请求。
正如您所见,通过使用Kubernetes来管理训练集群,我们可以将资源容量跟踪和资源隔离管理的工作从训练服务中卸载出来。这是选择在深度学习训练集群中构建训练集群管理的一个好处之一。
我们在这个示例服务中没有演示度量指标。通常情况下,度量指标是用于评估、比较和跟踪性能或生产的定量评估措施。特别是对于深度学习训练,我们通常定义两类度量指标:模型训练执行度量和模型性能度量。
模型性能度量衡量模型学习的质量。它包括每个训练迭代(epoch)的损失值和评估分数,以及最终模型的评估结果,如准确率、精确度和F1分数等。
现在让我们讨论如何将更多的训练代码引入到我们的示例训练服务中。在当前的实现中,我们使用请求中的算法变量来定义用户训练请求与训练代码之间的简单映射关系,其中算法变量必须与Docker镜像名称相等;否则,训练服务无法找到正确的镜像来运行模型训练。
以我们的意图分类训练为例。首先,我们需要将意图分类的Python训练代码Docker化为一个名为"intent-classification"的Docker镜像。然后,当用户发送一个带有参数algorithm='intent-classification'的训练请求时,Docker作业追踪器将使用算法名称(intent-classification)在本地Docker仓库中搜索"intent-classification"训练镜像,并将该镜像作为训练容器运行。
这种方法确实过于简化,但它示范了我们如何与数据科学家合作,为用户训练请求与实际训练代码之间定义一个正式的映射关系。在实践中,训练服务应该提供一组API,允许数据科学家以自助的方式注册训练代码。
一种可能的方法是在数据库中定义算法名称和训练代码的映射,并添加一些API来管理这个映射关系。提出的API可以是:
如果数据科学家想要添加一个新的算法类型,他们可以调用createAlgorithmMappingAPI,将新的训练镜像与新的算法名称注册到训练服务中。我们的用户只需要在训练请求中使用这个新的算法名称,就可以使用这个新算法进行模型训练。
如果数据科学家想要发布一个现有算法的新版本,他们可以调用updateAlgorithmVersionAPI来更新映射关系。我们的用户仍然使用相同的算法名称(比如intent-classification)发送请求,但他们不会意识到在幕后训练代码已经升级到了不同的版本。此外,值得指出的是,添加新的训练算法不会影响服务的公共API;只是使用了一个新的参数值。
在看过我们的示例训练服务后,让我们来看一下一个开源的训练服务。在本节中,我们将讨论Kubeflow项目中的一组开源训练操作符。这些训练操作符可以直接使用,并且可以独立地在任何Kubernetes集群中设置。
Kubeflow是一个成熟的、面向生产用例的开源机器学习系统。我们在附录B.4中简要介绍了它,还介绍了AmazonSageMaker和GoogleVertexAI。我们推荐使用Kubeflow训练操作符,因为它们设计良好,提供高质量的可扩展、可分布和健壮的训练功能。我们将首先讨论高级系统设计,然后讨论如何将这些训练操作符集成到您自己的深度学习系统中。
Kubeflow提供了一组训练操作符,例如TensorFlow操作符、PyTorch操作符、MXNet操作符和MPI操作符。这些操作符涵盖了所有主要的训练框架。每个操作符都具备在特定类型的训练框架中启动和监视训练代码(容器)的知识。
如果您计划在Kubernetes集群中运行模型训练,并希望建立自己的训练服务以降低操作成本,Kubeflow训练操作符是完美的选择。以下是三个原因:
安装简单,维护成本低—Kubeflow操作符可以立即使用;您只需执行几行Kubernetes命令即可使其在集群中工作。
兼容大多数训练算法和框架—只要您将训练代码容器化,就可以使用Kubeflow操作符来执行它。
Kubeflow的训练操作符遵循Kubernetes操作符(控制器)设计模式。如果我们理解这个模式,运行Kubeflow训练操作符并阅读其源代码就很直观。图3.11展示了控制器模式设计图。
Kubernetes的一切都是围绕资源对象和控制器构建的。Kubernetes的资源对象,如Pods、命名空间(Namespaces)和配置映射(ConfigMaps),是表示集群状态(期望状态和当前状态)的实体(数据结构)。控制器是一个控制循环,它对实际系统资源进行更改,使集群的当前状态更接近于资源对象中定义的期望状态。
为了方便扩展Kubernetes,Kubernetes允许用户定义自定义资源定义(CRD)对象,并注册自定义的控制器来处理这些CRD对象,这些控制器被称为操作符(operators)。如果您想了解更多关于控制器/操作符的信息,可以阅读“Kubernetes/sample-controller”GitHub存储库,该存储库实现了一个简单的控制器来监视CRD对象。这个示例控制器的代码可以帮助您理解操作符/控制器模式,对于阅读Kubeflow训练操作符的源代码非常有用。注意:在本节中,“控制器”和“操作符”这两个术语可以互换使用。
Kubeflow训练操作符(TensorFlow操作符、PyTorch操作符、MPI操作符)遵循Kubernetes操作符的设计。每个训练操作符会监视其自己类型的自定义资源定义对象,例如TFJob、PyTorchJob和MPIJob,并创建实际的Kubernetes资源来运行训练任务。
举个例子,TensorFlow操作符会处理集群中生成的任何TFJobCRD对象,并根据TFJob规范创建实际的服务和Pod。它会将TFJob对象的资源请求与实际的Kubernetes资源(如服务和Pod)进行同步,并不断努力使观察到的状态与期望的状态相匹配。请参考图3.12中的可视化工作流程。
每个操作符都可以为其自身类型的训练框架运行训练Pod。例如,TensorFlow操作符知道如何为使用TensorFlow编写的训练代码设置分布式训练Pod组。操作符从CRD定义中读取用户请求,创建训练Pod,并向每个训练Pod/容器传递正确的环境变量和命令行参数。您可以查看每个操作符代码中的reconcileJobs和reconcilePods函数以获取更多详细信息。
每个Kubeflow操作符还处理作业队列管理。因为Kubeflow操作符遵循Kubernetes操作符模式并在Pod级别创建Kubernetes资源,所以训练Pod的故障转移得到了很好的处理。例如,当一个Pod意外失败时,当前Pod数量会比期望的Pod数量少一个。在这种情况下,操作符中的reconcilePods逻辑将在集群中创建一个新的Pod,以确保实际的Pod数量等于CRD对象中定义的期望数量,从而解决故障转移的问题。
注意:在编写本书时,TensorFlow操作符正在成为全能的Kubeflow操作符。它旨在简化在Kubernetes上运行分布式或非分布式的TensorFlow/PyTorch/MXNet/XGBoost作业。无论最终结果如何,它都将基于我们在这里提到的设计构建,只是使用起来更加方便。
在本节中,我们将以PyTorch操作符为例,分为四个步骤来训练一个PyTorch模型。由于所有的Kubeflow训练操作符都遵循相同的使用模式,这些步骤也适用于其他操作符。
首先,在您的Kubernetes集群中安装独立的PyTorch操作符和PyTorchJobCRD。您可以在PyTorch操作符Git存储库的开发者指南中找到详细的安装说明。安装完成后,您可以在Kubernetes集群中找到一个运行的训练操作符pod和一个创建的CRD定义。以下是查询CRD的命令示例:
接下来,更新您的训练容器,使其从环境变量和命令行参数中读取参数输入。您可以稍后在CRD对象中传递这些参数。
第三步,创建一个PyTorchJobCRD对象来定义我们的训练请求。您可以通过首先编写一个YAML文件(例如pytorchCRD.yaml),然后在Kubernetes集群中运行kubectlcreate-fpytorchCRD.yaml来创建此CRD对象。PT-operator将检测到这个新创建的CRD对象,将其放入控制器的作业队列中,并尝试分配资源(Kubernetespods)来运行训练。示例3.8显示了一个PyTorchJobCRD的示例。
kind:PyTorchJobmetadata:name:pytorch-demospec:pytorchReplicaSpecs:Master:replicas:1restartPolicy:OnFailurecontainers:......Worker:replicas:1......spec:containers:-name:pytorch......env:-name:credentialsvalue:"/etc/secrets/user-gcp-sa.json"command:-"python3"-“-m”-"/opt/pytorch-mnist/mnist.py"-"--epochs=20"-“--batch_size=32”最后一步是监控。您可以使用kubectlget-oyamlpytorchjobs命令获取训练状态,该命令将列出所有pytorchjobs类型的CRD对象的详细信息。因为PyTorch操作符的控制器将持续更新最新的训练信息到CRD对象中,我们可以从中读取当前的状态。例如,以下命令将获取名称为pytorch-demo的PyTorchJob类型的CRD对象:
kubectlget-oyamlpytorchjobspytorch-demo-nkubeflow注意:在前面的示例中,我们使用了Kubernetes命令kubectl与PyTorch操作符进行交互。但是我们也可以通过发送RESTful请求到集群的KubernetesAPI来创建训练作业的CRD对象并查询其状态。新创建的CRD对象将触发控制器中的训练操作。这意味着Kubeflow训练操作符可以轻松地集成到其他系统中。
在图3.13中,现有系统的前端部分保持不变,例如前端门户网站。在计算后端方面,我们更改了内部组件,并与包装器训练服务进行通信以执行模型训练。包装器服务执行三个操作:首先,它管理作业队列;其次,它将现有格式的训练请求转换为Kubeflow训练操作符的CRD对象;第三,它从CRD对象中获取训练状态。通过添加包装器服务,我们可以将Kubeflow训练操作符轻松地作为任何现有深度学习平台/系统的训练后端。
从头开始构建一个质量上乘的训练系统需要大量的努力。您需要了解不同训练框架的细微差别,还要了解如何处理工程上的可靠性和可扩展性挑战。因此,如果您决定在Kubernetes上运行模型训练,我们强烈推荐采用Kubeflow训练操作符。这是一个开箱即用的解决方案,并且可以轻松移植到现有系统中。
主要的公共云供应商,如亚马逊、谷歌和微软,提供了他们的深度学习平台,如亚马逊SageMaker、谷歌VertexAI和Azure机器学习工作室。所有这些系统都声称提供完全托管的服务,支持整个机器学习工作流程,可以快速训练和部署机器学习模型。事实上,它们不仅涵盖了模型训练,还包括数据处理和存储、版本控制、故障排查、运维等等。
另一个使用公共云AI平台的原因是,您只有少数几个深度学习场景,并且它们很好地适应了公共云的标准用例。在这种情况下,为仅有几个应用程序构建复杂的深度学习系统是不值得的,因为它会消耗大量资源。
现在,让我们谈谈需要建立自己的训练方法的情况。如果您对系统有以下五个要求之一,建立自己的训练服务是正确的选择。
跨云平台
如果您希望应用程序具有跨云平台的能力,那么您不能使用AmazonSageMaker或GoogleVertexAI平台,因为这些系统是特定于云平台的。跨云平台的能力对于存储客户数据的服务非常重要,因为一些潜在客户对将其数据放入哪个云平台有特定要求。您希望您的应用程序能够在不同的云基础设施上无缝运行。
在公共云上构建跨云平台的常见方法是仅使用基础服务,例如虚拟机(VM)和存储,并在其之上构建应用程序逻辑。以模型训练为例,当使用亚马逊网络服务(AmazonWebServices)时,我们首先使用亚马逊EC2服务设置一个Kubernetes集群(AmazonElasticKubernetesService(AmazonEKS))来管理计算资源,然后使用Kubernetes接口构建自己的训练服务来启动训练作业。通过这种方式,当我们需要迁移到谷歌云(GoogleCloudPlatform,GCP)时,我们可以简单地将我们的训练服务应用于GCP的Kubernetes集群(GoogleKubernetesEngine),而大部分服务保持不变。
减少基础设施成本
与独立运行自己的服务相比,使用云服务提供商的AI平台将收取更高的费用。在原型阶段,您可能对账单不太关心,但产品发布后,您肯定会在意。以AmazonSageMaker为例,撰写本书时(2022年),SageMaker对m5.2xlarge型(八个虚拟CPU,32GB内存)机器每小时收费0.461美元。如果直接在此硬件规格上启动AmazonEC2实例(虚拟机),每小时收费0.384美元。通过构建自己的训练服务并直接在AmazonEC2实例上运行,平均可以节省近20%的模型构建费用。如果一个公司有多个团队每天进行模型训练,自建的训练系统将使您在竞争中占据优势。
定制化
云AI平台的另一个问题是它们在采用新技术方面总是存在延迟。例如,您必须等待SageMaker团队决定是否支持某种训练方法以及何时支持它,而有时这个决定可能不符合您的期望。深度学习是一个快速发展的领域。构建自己的训练服务可以帮助您采用最新的研究成果并快速调整策略,这将使您在激烈的竞争中占据优势。
通过合规审计
为了合法地运营某些业务,您需要获得合规法律法规的认证,例如HIPAA(医疗保险可携带性和责任法案)或CCPA(加利福尼亚消费者隐私法)。这些认证要求您不仅提供证据证明您的代码符合这些要求,还要证明您的应用程序运行的基础架构符合要求。如果您的应用程序是建立在AmazonSageMaker和GoogleVertexAI平台上,它们也需要符合合规要求。由于云供应商是一个黑盒子,运行合规性检查并提供证据是一项繁琐的任务。
训练服务的主要目标是管理计算资源和训练执行。
一个复杂的训练服务遵循四个原则:通过统一的接口支持各种模型训练代码;降低训练成本;支持模型的可复现性;具有高扩展性和可用性,并处理计算隔离。
了解通用的模型训练代码模式使我们能够从训练服务的角度将代码视为黑盒。
容器化是处理深度学习训练方法和框架多样性的关键。
通过将训练代码进行容器化并定义清晰的通信协议,训练服务可以将训练代码视为黑盒,在单个设备上或分布式地执行训练。这也有利于数据科学家,因为他们可以专注于模型算法开发,而不必担心训练执行。
Kubeflow训练操作员是一组基于Kubernetes的开源训练应用程序。这些操作员可以直接使用,并且可以轻松集成到任何现有系统中作为模型训练后端。Kubeflow训练操作员支持分布式和非分布式训练。
使用公共云训练服务可以快速构建深度学习应用。另一方面,构建自己的训练服务可以降低训练操作成本,提供更多定制选项,并保持云无关性。