分布式机器学习模式-全-
分布式机器学习模式(全)
原文:Distributed Machine Learning Patterns
译者:飞龙
前置材料
前言
近年来,机器学习取得了巨大的进步,但大规模机器学习仍然具有挑战性。以模型训练为例。随着 TensorFlow、PyTorch 和 XGBoost 等机器学习框架的多样性,在分布式 Kubernetes 集群上自动训练机器学习模型的过程并不容易。不同的模型需要不同的分布式训练策略,例如利用参数服务器和使用网络结构的集体通信策略。在现实世界的机器学习系统中,许多其他重要组件,如数据摄取、模型服务和工作流程编排,必须精心设计,以使系统可扩展、高效且易于迁移。对于几乎没有 DevOps 经验的机器学习研究人员来说,很难启动和管理分布式训练任务。
许多书籍都涉及机器学习或分布式系统,然而,目前还没有一本书能够同时讨论两者的结合,并弥合它们之间的差距。本书将介绍在分布式环境中大规模机器学习系统的许多模式和最佳实践。
本书还包括一个动手项目,构建了一个端到端分布式机器学习系统,该系统集成了本书中涵盖的许多模式。我们将使用包括 Kubernetes、Kubeflow、TensorFlow 和 Argo 在内的多种最先进技术来实现系统。这些技术是构建原生云分布式机器学习系统的首选,使其非常可扩展和易于迁移。
我在这个领域工作多年,包括维护本书中使用的某些开源工具,并领导团队提供可扩展的机器学习基础设施。在我的日常工作中,设计系统之初或改进现有系统时,这些模式和它们的权衡总是被考虑在内。我希望这本书也能对你有所帮助!
致谢
首先,我要感谢我的妻子,文璇。你一直支持我,在我努力完成这本书的过程中,你总是耐心地倾听,总是让我相信我能完成这个项目,并在我在写书的时候帮助照顾孩子。感谢我的三个可爱的孩子,他们在我遇到困难时总是给我带来笑容。我爱你们所有人。
接下来,我想感谢我的前开发编辑 Patrick Barb,感谢你多年来对我的耐心和指导。我还要感谢 Michael Stephens 指导本书的方向,并在我怀疑自己时帮助我度过难关。还要感谢 Karen Miller 和 Malena Selic 提供顺利的过渡,并帮助我快速进入生产阶段。你们对这本书质量的承诺使它对每一位读者都变得更好。还要感谢所有在 Manning 与我一起参与本书生产和推广的人。这确实是一个团队的努力。
还要感谢我的技术编辑 Gerald Kuch,他带来了超过 30 年的行业经验,包括几家大型公司和初创企业以及研究实验室。Gerald 在数据结构和算法、函数式编程、并发编程、分布式系统、大数据、数据工程和数据科学方面的知识和教学经验,使他在手稿开发过程中成为我的宝贵资源。
最后,我还要感谢在本书开发过程中不同阶段抽出时间阅读我的手稿并提供宝贵反馈的审稿人。感谢 Al Krinker、Aldo Salzberg、Alexey Vyskubov、Amaresh Rajasekharan、Bojan Tunguz、Cass Petrus、Christopher Kottmyer、Chunxu Tang、David Yakobovitch、Deepika Fernandez、Helder C. R. Oliveira、Hongliang Liu、James Lamb、Jiri Pik、Joel Holmes、Joseph Wang、Keith Kim、Lawrence Nderu、Levi McClenny、Mary Anne Thygesen、Matt Welke、Matthew Sarmiento、Michael Aydinbas、Michael Kareev、Mikael Dautrey、Mingjie Tang、Oleksandr Lapshyn、Pablo Roccatagliata、Pierluigi Riti、Prithvi Maddi、Richard Vaughan、Simon Verhoeven、Sruti Shivakumar、Sumit Pal、Vidhya Vinay、Vladimir Pasman 和 Wei Yan,你们的建议帮助我改进了这本书。
关于这本书
《分布式机器学习模式》 中充满了在云端的分布式 Kubernetes 集群上运行机器学习系统的实用模式。每个模式都是为了帮助解决在构建分布式机器学习系统时面临的常见挑战而设计的,包括支持分布式模型训练、处理意外故障和动态模型服务流量。现实场景提供了如何应用每个模式的清晰示例,以及每种方法的潜在权衡。一旦你掌握了这些前沿技术,你将把它们全部付诸实践,并通过构建一个全面的分布式机器学习系统来完成。
适合阅读这本书的人
《分布式机器学习模式》 适合熟悉机器学习算法基础以及在生产环境中运行机器学习的数据分析师、数据科学家和软件工程师。读者应熟悉 Bash、Python 和 Docker 的基础知识。
本书组织结构:路线图
本书分为三个部分,共涵盖九个章节。
第一部分提供了一些关于分布式机器学习系统的背景和概念。我们将讨论机器学习应用的增长规模和分布式系统的复杂性,并介绍在分布式系统和分布式机器学习系统中常见的一些模式。
第二部分展示了机器学习系统各个组件中涉及的一些挑战,并介绍了一些在行业中广泛采用的成熟模式来解决这些挑战:
-
第二章介绍了数据摄取模式,包括批处理、分片和缓存,以有效地处理大数据集。
-
第三章包括了在分布式模型训练中经常看到的三个模式,涉及参数服务器、集体通信、弹性以及容错性。
-
第四章展示了复制的服务、分片的服务和事件驱动处理在模型服务中的有用性。
-
第五章描述了几个工作流程模式,包括扇入和扇出模式、同步和异步模式以及步骤记忆化模式,这些模式通常用于创建复杂和分布式的机器学习工作流程。
-
第六章以调度和元数据模式结束这一部分,这些模式对于操作可能很有用。
第三部分深入到端到端的机器学习系统,以应用我们之前学到的知识。读者将获得实际经验,实现在这个项目中之前学到的许多模式:
-
第七章介绍了项目背景和系统组件。
-
第八章涵盖了我们将用于项目的技术的根本原理。
-
第九章通过一个端到端的机器学习系统的完整实现来结束本书。
通常情况下,如果读者已经知道什么是分布式机器学习系统,可以跳过第一部分。第二部分的所有章节都可以独立阅读,因为每个章节都涵盖了分布式机器学习系统中的不同视角。第七章和第八章是第九章中我们构建的项目的前提条件。如果读者已经熟悉这些技术,可以跳过第八章。
关于代码
您可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为livebook.manning.com/book/distributed-machine-learning-patterns。本书中示例的完整代码可以从 Manning 网站www.manning.com和 GitHub 仓库github.com/terrytangyuan/distributed-ml-patterns下载。请将任何问题提交到 GitHub 仓库,它将得到积极监控和维护。
liveBook 讨论论坛
购买《分布式机器学习模式》包括免费访问 Manning 的在线阅读平台 liveBook。使用 liveBook 的独特讨论功能,您可以在全球范围内或针对特定章节或段落附加评论。为自己做笔记、提出和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/distributed-machine-learning-patterns/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 的论坛和行为准则。
曼宁对读者的承诺是提供一个平台,在这里读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未支付报酬)。我们建议您尝试向作者提出一些挑战性的问题,以免他们的兴趣转移!只要这本书有售,论坛和之前讨论的存档将可通过出版社的网站访问。
关于作者

殷唐是 Akuity 的创始人工程师,为开发者构建一个企业级平台。他之前在阿里巴巴和 Uptake 领导数据科学和工程团队,专注于 AI 基础设施和 AutoML 平台。他是 Argo 和 Kubeflow 的项目负责人,TensorFlow 和 XGBoost 的维护者,以及多个开源项目的作者。此外,殷唐还著有三本机器学习书籍和几篇出版物。他是各种会议的常客,并在多个组织担任技术顾问、领导者和导师。
关于封面插图
《分布式机器学习模式》封面上的形象是“科孚人”,或“科孚岛人”,取自雅克·格拉塞·德·圣索沃尔的收藏,该收藏于 1797 年出版。每一幅插图都是手工精细绘制和着色的。
在那些日子里,仅凭人们的着装就可以轻易地识别出他们的居住地以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地区文化的书封面来庆祝计算机行业的创新精神和主动性,这些文化通过像这样的收藏品中的图片被重新带回生活。
1 分布式机器学习系统简介
本章涵盖
-
处理大规模机器学习应用中的规模增长
-
建立可扩展和可靠的分布式系统模式
-
使用分布式系统中的模式和构建可重用模式
机器学习系统在当今变得越来越重要。推荐系统根据用户反馈和交互学习生成具有正确上下文的潜在感兴趣推荐,异常事件检测系统帮助监控资产以防止因极端条件而导致的停机,欺诈检测系统保护金融机构免受安全攻击和恶意欺诈行为。
对构建大规模分布式机器学习系统的需求日益增长。如果一个数据分析师、数据科学家或软件工程师在 Python 中构建机器学习模型方面有基本知识和实践经验,并希望通过学习如何构建更稳健、可扩展和可靠的系统来更进一步,那么这本书就是正确的选择。尽管在生产环境或分布式系统方面的经验不是必需的,但我期望处于这种位置的读者至少对在生产环境中运行的机器学习应用有所了解,并且应该至少编写过一年的 Python 和 Bash 脚本。
能够处理大规模问题,并将你在笔记本电脑上开发的内容扩展到大型分布式集群中是非常令人兴奋的。本书介绍了各种模式中的最佳实践,这些模式可以帮助你加快机器学习模型的开发和部署,使用来自不同工具的自动化,并从硬件加速中受益。阅读本书后,你将能够选择并应用正确的模式来构建和部署分布式机器学习系统;在机器学习工作流中适当使用常见的工具,如 TensorFlow (www.tensorflow.org)、Kubernetes (kubernetes.io)、Kubeflow (www.kubeflow.org)和 Argo Workflows;并在 Kubernetes 中管理自动化机器学习任务获得实践经验。第九章的一个全面、实践的项目提供了一个机会,可以构建一个使用我们在本书第二部分学到的许多模式的真实生活分布式机器学习系统。此外,在以下章节的一些部分末尾的补充练习回顾了我们学到的内容。
1.1 大规模机器学习
机器学习应用的范围已经变得前所未有的大。用户要求更快地响应以满足现实生活的需求,机器学习管道和模型架构也在变得更加复杂。在本节中,我们将更详细地讨论规模的增长以及我们可以采取哪些措施来应对挑战。
1.1.1 规模的增长
随着机器学习需求的增长,构建机器学习系统的复杂性也在增加。机器学习研究人员和数据分析师不再满足于在笔记本电脑上构建简单的机器学习模型,这些模型基于几 GB 的 Microsoft Excel 表格。由于需求的增长和复杂性,机器学习系统必须具备处理不断增长规模的能力,包括历史数据的增加量;频繁的 incoming 数据批次;复杂的机器学习架构;大量的模型服务流量;以及复杂的端到端机器学习管道。
让我们考虑两个场景。首先,想象一下你有一个小型机器学习模型,它是在一个小型数据集(小于 1 GB)上训练的。这种方法可能适合你当前的分析,因为你有一台具有足够计算资源的笔记本电脑。但你意识到数据集每小时增长 1 GB,所以原始模型在现实生活中不再有用和可预测。假设你想构建一个时间序列模型,预测火车组件在下一个小时内是否会失败,以防止故障和停机。在这种情况下,我们必须构建一个机器学习模型,该模型使用从原始数据和每小时到达的最新数据中获得的知识来生成更准确的预测。不幸的是,你的笔记本电脑计算资源固定,不再足以构建一个使用整个数据集的新模型。
其次,假设你已经成功训练了一个模型并开发了一个简单的 Web 应用程序,该应用程序使用训练好的模型根据用户的输入进行预测。在开始时,这个 Web 应用程序可能运行良好,生成准确的预测,用户对结果非常满意。这个用户的朋友们听说了这段美好的体验,决定也试试看,于是他们坐在同一个房间里打开了网站。讽刺的是,当他们试图查看预测结果时,开始看到更长的延迟。延迟的原因是,运行 Web 应用程序的单个服务器无法处理随着应用程序越来越受欢迎而增加的用户请求。这种情况是许多机器学习应用在从测试产品发展到流行应用过程中会遇到的一个常见挑战。这些应用需要建立在可扩展的机器学习系统模式之上,以处理不断增长的吞吐量规模。
1.1.2 我们能做什么?
当数据集太大而无法适应单个机器时,就像 1.1.1 节中的第一个场景一样,我们如何存储这个大数据集?也许我们可以将数据集的不同部分存储在不同的机器上,然后通过在不同机器上按顺序循环遍历数据集的不同部分来训练机器学习模型。
如果我们有一个如图 1.1 所示的 30 GB 数据集,我们可以将其分为三个 10 GB 数据的分区,每个分区位于具有足够磁盘存储的独立机器上。然后,我们可以逐个消费分区,而无需同时使用整个数据集来训练机器学习模型。

图 1.1 在三个具有足够磁盘存储的独立机器上将大型数据集分为三个分区的示例
然后,我们可能会问,如果遍历数据集的不同部分非常耗时,会发生什么。假设手头的数据集已经被分为三个分区。如图 1.2 所示,首先,我们在第一台机器上初始化机器学习模型,然后使用第一个数据分区中的所有数据进行训练。接下来,我们将训练好的模型转移到第二台机器上,该机器继续使用第二个数据分区进行训练。如果每个分区都很大且耗时,我们将花费大量时间等待。

图 1.2 在每个数据分区上依次训练模型的示例
在这种情况下,我们可以考虑添加工作节点。每个工作节点负责消费每个数据分区,所有工作节点并行训练相同的模型,而不需要等待他人。这种方法无疑有助于加快模型训练过程。但是,如果一些工作节点完成消费它们负责的数据分区并希望同时更新模型,我们应该首先使用哪个工作节点的结果(梯度)来更新模型?然后,我们必须考虑性能和模型质量之间的冲突和权衡。如图 1.2 所示,如果第一个工作节点使用的数据分区由于数据收集过程比第二个工作节点使用的数据分区更为严格而质量更好,那么首先使用第一个工作节点的结果将产生更准确的模型。另一方面,如果第二个工作节点有更小的分区,它可能训练得更快,因此我们可以开始使用该工作节点的计算资源来训练新的数据分区。当添加更多工作节点,如图 1.2 中所示的三个工作节点时,不同工作节点在数据消费完成时间上的冲突变得更加明显。
类似地,如果使用训练好的机器学习模型进行预测的应用观察到流量非常重,我们是否可以简单地添加服务器,每个新服务器处理一定比例的流量?遗憾的是,答案并不那么简单。这种简单的解决方案需要考虑其他因素,例如决定最佳的负载均衡策略以及在不同的服务器上处理重复请求。
我们将在本书的第二部分学习更多关于处理这些类型问题的知识。目前,主要的收获是我们已经建立了模式和最佳实践来处理某些情况,我们将使用这些模式来最大限度地利用我们有限的计算资源。
1.2 分布式系统
单台机器或笔记本电脑无法满足训练大量数据的大型机器学习模型的性能要求。我们需要编写可以在多台机器上运行并被世界各地的人访问的程序。在本节中,我们将讨论分布式系统是什么,并讨论一个在分布式系统中经常使用的具体示例模式。
1.2.1 什么是分布式系统?
计算机程序已经从只能在单台机器上运行发展到与多台机器协同工作。对计算能力的日益增长的需求和对更高效率、可靠性和可扩展性的追求推动了由数百或数千台计算机组成的大型数据中心的发展,这些计算机通过共享网络进行通信,这导致了分布式系统的开发。分布式系统是指组件位于不同的联网计算机上,并且可以通过消息传递相互通信以协调工作负载并协同工作。
图 1.3 展示了由两台机器组成的小型分布式系统,这两台机器通过消息传递相互通信。一台机器包含两个 CPU,另一台机器包含三个 CPU。显然,一台机器除了 CPU 之外还包含其他计算资源;我们在这里仅使用 CPU 进行说明。在现实世界的分布式系统中,机器的数量可以非常大——根据用例,可能有数万台。具有更多计算资源的机器可以处理更大的工作负载,并将结果与其他机器共享。

图 1.3 一个由两台机器组成的小型分布式系统示例,这两台机器具有不同数量的计算资源,通过消息传递相互通信
1.2.2 复杂性和模式
这些分布式系统可以在多台机器上运行,并且可以被全球各地的用户访问。它们通常很复杂,需要精心设计以确保更高的可靠性和可扩展性。不良的架构考虑可能导致问题,通常规模很大,并导致不必要的成本。
分布式系统有很多好的模式和可重用的组件。例如,在批处理系统中的工作队列模式确保每项工作都是独立的,并且可以在一定时间内无需任何干预进行处理。此外,工作者可以扩展和缩减,以确保工作负载能够得到妥善处理。
图 1.4 描述了七个工作项,每个工作项可能是一个需要由系统在处理队列中修改为灰度的图像。每个现有的三个工作者从处理队列中取出两个到三个工作项,确保没有工作者空闲,以避免计算资源的浪费,并通过同时处理多个图像来最大化性能。这种性能之所以可能,是因为每个工作项都是独立的。

图 1.4 使用工作队列模式修改图像为灰度的批处理系统示例
1.3 分布式机器学习系统
分布式系统不仅对通用计算任务有用,对机器学习应用也同样有用。想象一下,我们可以在分布式系统中使用多台具有大量计算资源的机器来消费大型数据集的部分,存储大型机器学习模型的不同分区,等等。考虑到可扩展性和可靠性,分布式系统可以大大加快机器学习应用的速度。在本节中,我们将介绍分布式机器学习系统,展示那些系统中常用的一些模式,并讨论一些实际场景。
1.3.1 什么是分布式机器学习系统?
一个 分布式机器学习系统 是一个由负责机器学习应用中不同步骤的步骤和组件组成的分布式系统,例如数据摄取、模型训练和模型服务。它使用与分布式系统类似的模式和最佳实践,以及专门为机器学习应用设计的模式。通过精心设计,分布式机器学习系统在处理大规模问题时更具可扩展性和可靠性,例如大型数据集、大型模型、重模型服务流量以及复杂的模型选择或架构优化。
1.3.2 是否有类似的模式?
为了处理将在实际应用中部署的机器学习系统的日益增长的需求和规模,我们需要仔细设计分布式机器学习管道中的组件。设计通常是复杂的,但使用良好的模式和最佳实践可以让我们加快机器学习模型的开发和部署,利用不同工具的自动化,并从硬件加速中受益。
分布式机器学习系统中也存在类似的模式。例如,可以使用多个工作节点异步训练机器学习模型,每个工作节点负责消费数据集的特定分区。这种方法类似于在分布式系统中使用的任务队列模式,可以显著加快模型训练过程。图 1.5 说明了我们如何通过用数据分区替换工作项来将此模式应用于分布式机器学习系统。每个工作节点从存储在数据库中的原始数据中获取一些数据分区,然后使用它们来训练一个集中的机器学习模型。

图 1.5 分布式机器学习系统中应用任务队列模式的示例
在机器学习系统中,除了通用分布式系统外,常用的另一种示例模式是用于分布式模型训练的参数服务器模式。如图 1.6 所示,参数服务器负责存储和更新训练模型的特定部分。每个工作节点负责获取数据集的特定部分,这些部分将用于更新模型参数的特定部分。当模型太大而无法适应单个服务器时,这种模式非常有用,此时可以专门使用参数服务器来存储模型分区,而不必分配不必要的计算资源。

图 1.6 分布式机器学习系统中应用参数服务器模式的示例
本书第二部分将介绍这些模式。目前,请记住,分布式机器学习系统中的某些模式也出现在通用分布式系统中,以及专门设计来处理大规模机器学习工作负载的模式。
1.3.3 我们应该在何时使用分布式机器学习系统?
如果数据集太大而无法适应我们的本地笔记本电脑,如图 1.1 和 1.2 所示,我们可以使用数据分区或引入额外的工人来加速模型训练。当以下任何一种情况发生时,我们应该开始考虑设计分布式机器学习系统:
-
模型很大,由数百万个参数组成,单个机器无法存储,必须在不同的机器上进行分区。
-
机器学习应用需要处理越来越多的重流量,而单个服务器已无法管理。
-
当前任务涉及模型生命周期的许多部分,例如数据摄入、模型服务、数据/模型版本控制和性能监控。
-
我们希望使用大量计算资源进行加速,例如每台服务器都配备了许多 GPU 的数十台服务器。
如果发生任何这些情况,通常是一个迹象,表明在不久的将来将需要一个设计良好的分布式机器学习系统。
1.3.4 我们在什么情况下不应该使用分布式机器学习系统?
尽管分布式机器学习系统在许多情况下都有帮助,但它通常更难设计,并且需要经验才能高效地操作。开发和维护这样一个复杂的系统涉及额外的开销和权衡。如果你遇到以下任何情况,请坚持使用已经工作得很好的简单方法:
-
数据集很小,例如小于 10 GB 的 CSV 文件。
-
模型简单,不需要复杂的计算,例如线性回归。
-
计算资源有限,但对于手头的任务来说是足够的。
1.4 本书我们将学习的内容
在本书中,我们将学习选择和应用正确的模式来构建和部署分布式机器学习系统,以获得管理和自动化机器学习任务的实际经验。我们将使用几个流行的框架和尖端技术来构建分布式机器学习工作流程的组件,包括以下内容:
-
TensorFlow (
www.tensorflow.org) -
Kubernetes (
kubernetes.io) -
Kubeflow (
www.kubeflow.org) -
Docker (
www.docker.com) -
Argo Workflows (
argoproj.github.io/workflows/)
本书最后一部分的一个综合实战项目包括一个端到端的分布式机器学习管道系统。图 1.7 是我们将要构建的系统架构图。我们将通过以下章节中涵盖的许多模式获得实践经验。处理大规模问题和将我们在个人笔记本电脑上开发的内容扩展到大型分布式集群应该是令人兴奋的。

图 1.7 本书最后一部分我们将构建的端到端机器学习系统架构图
我们将使用 TensorFlow 和 Python 构建用于各种任务的机器学习和深度学习模型,例如基于真实数据集构建有用的特征、训练预测模型和进行实时预测。我们还将使用 Kubeflow 在 Kubernetes 集群中运行分布式机器学习任务。此外,我们将使用 Argo Workflows 构建一个由分布式机器学习系统许多重要组件组成的机器学习管道。这些技术的基础知识在第二章中介绍,我们将在第二部分中获得实践经验。表 1.1 显示了本书将涵盖的关键技术和示例用途。
表 1.1 本书涵盖的技术及其用途
| 技术 | 用途 |
|---|---|
| TensorFlow | 构建机器学习和深度学习模型 |
| Kubernetes | 管理分布式环境和资源 |
| Kubeflow | 在 Kubernetes 集群上轻松提交和管理分布式训练作业 |
| Argo Workflows | 定义、编排和管理工作流 |
| Docker | 构建和管理用于启动容器化环境的镜像 |
在我们深入到第二章的细节之前,我建议读者具备在 Python 中构建机器学习模型的基本知识和实践经验。尽管在生产环境或分布式系统中的经验不是必需的,但我期望处于这种位置的读者至少对在生产环境中运行的机器学习应用有所了解,并且至少已经编写了 Python 和 Bash 脚本一年以上。此外,了解 Docker 的基础知识并能够使用 Docker 命令行界面管理镜像/容器是必需的。熟悉基本的 YAML 语法有帮助但不是必需的;语法直观,应该可以在学习过程中轻松掌握。如果这些主题大部分对你来说都是新的,我建议你在继续阅读之前从其他资源中了解更多相关信息。
摘要
-
在实际应用中部署的机器学习系统通常需要处理更大数据集和更重的模型服务流量不断增长的问题。
-
设计大规模分布式机器学习系统并非易事。
-
分布式机器学习系统通常是一个由许多组件组成的管道,例如数据摄取、模型训练、服务和监控。
-
使用良好的模式来设计机器学习系统的组件可以加快机器学习模型的开发和部署,使不同工具的自动化功能得以使用,并从硬件加速中受益。
2 数据摄取模式
本章涵盖
-
理解数据摄取及其责任
-
通过批量消耗较小的数据集来在内存中处理大规模数据集(批量模式)
-
在多台机器上将极大规模的数据集预处理为更小的数据块(分片模式)
-
为多次训练轮次检索和重新访问相同的 dataset(缓存模式)
第一章讨论了现代机器学习应用的增长规模,如更大的数据集和更重的模型服务流量。它还讨论了构建分布式系统的复杂性和挑战,特别是针对机器学习应用的分布式系统。我们了解到,分布式机器学习系统通常是一个由许多组件组成的管道,如数据摄取、模型训练、服务、监控,并且有一些既定的模式可用于设计每个组件以处理现实世界机器学习应用的规模和复杂性。
所有数据分析师和科学家都应该对数据摄取有一定程度的了解,无论是通过实际构建数据摄取组件的动手经验,还是简单地使用来自工程团队或客户的 dataset。设计一个好的数据摄取组件并不简单,需要理解我们想要用于构建机器学习模型的 dataset 的特征。幸运的是,我们可以遵循既定的模式,在可靠和高效的基础上构建该模型。
本章探讨了数据摄取过程中的一些挑战,并介绍了一些在行业中广泛采用的既定模式。在第 2.3 节中,我们将使用批量模式来处理和准备大型数据集以进行模型训练,无论是我们使用的机器学习框架无法处理大型数据集,还是需要框架底层实现的领域专业知识。在第 2.4 节中,我们将学习如何应用分片模式将极大规模的数据集分割成多个数据分片,这些数据分片分布在多个工作机器上;然后,随着我们添加负责独立在每个数据分片上训练模型的工作机器,我们加快了训练过程。第 2.5 节介绍了缓存模式,当重新访问和处理用于多轮模型训练的先前使用的 dataset 时,它可以大大加快数据摄取过程。
2.1 什么是数据摄取?
假设我们手头有一个数据集,我们希望构建一个机器学习系统,从这个数据集中构建机器学习模型。我们首先应该考虑什么?答案是相当直观的:首先,我们应该更好地理解数据集。数据集是从哪里来的,是如何收集的?数据集的来源和大小是否随时间变化?处理数据集的基础设施需求是什么?我们应该首先提出这些问题。在我们开始构建分布式机器学习系统之前,我们还应该考虑可能影响处理数据集过程的不同观点。我们将在本章剩余部分的示例中探讨这些问题和考虑因素,并学习如何通过使用不同的既定模式来解决我们可能遇到的一些问题。
数据摄取 是一个监控数据源、一次性(非流式)或以流式方式消费数据,并为机器学习模型的训练过程进行预处理的流程。简而言之,流式数据摄取通常需要长时间运行的过程来监控数据源的变化;非流式数据摄取以离线批处理作业的形式发生,按需处理数据集。此外,在流式数据摄取中,数据随时间增长,而非流式数据摄取中数据集的大小是固定的。表 2.1 总结了这些差异。
表 2.1 比较机器学习应用中的流式和非流式数据摄取
| 流式数据摄取 | 非流式数据摄取 | |
|---|---|---|
| 数据集大小 | 随时间增加 | 大小固定 |
| 基础设施需求 | 长时间运行的过程来监控数据源的变化 | 离线批处理作业来按需处理数据集 |
本章剩余部分将专注于从非流式视角的数据摄取模式,但它们也可以应用于流式数据摄取。
数据摄取是机器学习流程中的第一步,也是不可避免的一步,如图 2.1 所示。如果没有正确摄取的数据集,机器学习流程中的其余过程将无法进行。

图 2.1 表示机器学习流程的流程图。请注意,数据摄取是流程中的第一步。
下一个部分介绍了 Fashion-MNIST 数据集,我将用它来展示本章剩余部分中的模式。我专注于构建分布式机器学习应用中的数据摄取模式,这些模式与在本地机器或笔记本电脑上发生的数据摄取不同。分布式机器学习应用中的数据摄取通常更复杂,需要精心设计来处理大规模数据集或快速增长的数据集。
2.2 The Fashion-MNIST dataset
LeCun 等人创建的 MNIST 数据集(yann.lecun.com/exdb/mnist/)是图像分类中最广泛使用的数据库之一。它包含从手写数字图像中提取的 60,000 张训练图像和 10,000 张测试图像;在机器学习研究社区中广泛用作基准数据集,以验证最先进的算法和机器学习模型。图 2.2 展示了一些手写数字的示例图像,每行代表特定手写数字的图像。

图 2.2 手写数字 0 到 9 的示例图像截图,每行代表特定手写数字的图像(来源:Josep Steffan,许可协议为 CC BY-SA 4.0)
尽管在社区中得到了广泛的应用,研究人员发现这个数据集不适合区分强模型和弱模型;现在许多简单的模型都能达到超过 95% 的分类准确率。因此,MNIST 数据集现在更多地作为一项理智检查,而不是基准。
注意:MNIST 数据集的创建者保留了一份在数据集上测试过的机器学习方法的列表。在 1998 年发表的关于 MNIST 数据集的原始论文“应用于文档识别的基于梯度的学习”中(yann.lecun.com/exdb/publis/index.xhtml#lecun-98),LeCun 等人表示他们使用支持向量机模型将错误率降至 0.8%。2017 年发布了一个类似但扩展的数据集,称为 EMNIST。EMNIST 包含 240,000 张训练图像和 40,000 张测试图像的手写数字和字符。
在本书的多个示例中,我将不会使用 MNIST 数据集,而是将重点放在一个数量级相似但相对更复杂的数据集上:2017 年发布的 Fashion-MNIST 数据集(github.com/zalandoresearch/fashion-mnist)。Fashion-MNIST 是由 Zalando 的文章图像组成的数据库,包括 60,000 个示例的训练集和 10,000 个示例的测试集。每个示例是一个与十个类别之一的标签相关联的 28 × 28 灰度图像。Fashion-MNIST 数据集旨在作为原始 MNIST 数据集的直接替代品,用于基准测试机器学习算法。它使用相同的图像大小和结构进行训练和测试分割。
图 2.3 展示了 Fashion-MNIST 中所有 10 个类别(T 恤/上衣、裤子、开衫、连衣裙、外套、凉鞋、衬衫、运动鞋、包和踝靴)的图像集合。每个类别占据了截图的三行。

图 2.3 Fashion-MNIST 数据集中所有 10 个类别(T 恤/上衣、裤子、开衫、连衣裙、外套、凉鞋、衬衫、运动鞋、包和踝靴)的图像集合截图(来源:Zalando SE,许可协议为 MIT 许可证)
图 2.4 提供了对训练集中前几个示例图像及其相应文本标签的近距离观察。接下来,我将讨论案例研究的场景。

图 2.4 训练集中的前几个示例图像(来源:Zalando SE,MIT 许可证授权)
假设我们已经下载了 Fashion-MNIST 数据集。压缩版本在磁盘上应该只占用 30 MB。尽管数据集很小,但使用现有的实现一次性将下载的数据集加载到内存中是微不足道的。例如,如果我们使用 TensorFlow 这样的机器学习框架,我们可以用几行 Python 代码下载并加载整个 Fashion-MNIST 数据集到内存中,如下所示。
列表 2.1 使用 TensorFlow 将 Fashion-MNIST 数据集加载到内存中
> import tensorflow as tf ❶
>
> train, test = tf.keras.datasets.fashion_mnist.load_data() ❷
32768/29515 [=================================] - 0s 0us/step
26427392/26421880 [==============================] - 0s 0us/step
8192/5148 [===============================================] - 0s 0us/step
4423680/4422102 [==============================] - 0s 0us/step
❶ 加载 TensorFlow 库
❷ 下载 Fashion-MNIST 数据集并将其加载到内存中
或者,如果数据集已经存在于内存中——例如以 NumPy (numpy.org) 数组的形式——我们可以从内存中的数组表示形式加载数据集到机器学习框架接受的格式中,例如 tf.Tensor 对象,这可以很容易地用于后续的模型训练。以下列表展示了示例。
列表 2.2 将 Fashion-MNIST 数据集从内存加载到 TensorFlow 中
> from tensorflow.data import Dataset
>
> images, labels = train ❶
> images = images/255 ❷
>
> dataset = Dataset.from_tensor_slices((images, labels)) ❸
> dataset ❹
<TensorSliceDataset shapes: ((28, 28), ()), types: (tf.float64, tf.uint8)>
❶ 将训练数据集对象拆分为图像和标签
❷ 标准化图像
❸ 将内存中的数组表示形式加载到 tf.data.Dataset 对象中,这将使其更容易在 TensorFlow 中进行训练
❹ 检查数据集的信息,例如形状和数据类型
2.3 批处理模式
现在我们已经知道了 Fashion-MNIST 数据集的样子,让我们考察一下在现实场景中可能会遇到的一个潜在问题。
2.3.1 问题:在内存有限的情况下对 Fashion MNIST 数据集执行昂贵的操作
虽然将像 Fashion-MNIST 这样的小型数据集加载到内存中以便为模型训练做准备很容易,但在现实世界的机器学习应用中,这个过程可能具有挑战性。例如,列表 2.1 中的代码片段可以用来将 Fashion-MNIST 加载到内存中,以便在 TensorFlow 中进行模型训练;它将特征和标签数组嵌入我们的 TensorFlow 图中作为 tf.constant()操作。这个过程对于小型数据集来说效果很好,但它浪费了内存,因为 NumPy 数组的内容将被复制多次,并且可能会遇到 TensorFlow 使用的 tf.GraphDef 协议缓冲区的 2 GB 限制。在现实世界的应用中,数据集通常要大得多,尤其是在数据集随时间增长的分步机器学习系统中。
图 2.5 显示了 1.5GB 的内存中 NumPy 数组表示,该表示将使用 tf.constant()操作复制两次。这个操作会导致内存不足错误,因为总大小 3GB 超过了 TensorFlow 使用的 tf.GraphDef 协议缓冲区的最大大小。

图 2.5 一个 1.5GB 内存中 NumPy 数组表示的示例,当转换为 tf.GraphDef 协议缓冲区时遇到内存不足错误
在不同的机器学习或数据加载框架中,这类问题经常发生。用户可能没有以最佳方式使用特定框架,或者框架可能无法处理更大的数据集。
此外,即使是像 Fashion-MNIST 这样的小型数据集,在将数据集输入到模型之前,我们也可能执行额外的计算,这在需要额外转换和清理的任务中很常见。对于计算机视觉任务,图像通常需要调整大小、归一化或转换为灰度,或者可能需要更复杂的数学运算,例如卷积运算。这些操作可能需要大量的额外内存空间分配,但我们可能没有很多计算资源可用,在将整个数据集加载到内存之后。
2.3.2 解决方案
考虑到 2.2 节中提到的第一个问题。我们希望使用 TensorFlow 的 from_tensor_slices() API 将 Fashion-MNIST 数据集从内存中的 NumPy 数组表示加载到 TensorFlow 模型训练程序可以使用的 tf.Dataset 对象中。然而,由于 NumPy 数组的内容将被多次复制,我们可能会遇到 tf.GraphDef 协议缓冲区的 2GB 限制。因此,我们无法加载超过此限制的更大数据集。
对于像 TensorFlow 这样的特定框架,看到这类问题并不罕见。在这种情况下,解决方案很简单,因为我们没有充分利用 TensorFlow。其他 API 允许我们在不首先将整个数据集加载到内存表示的情况下加载大型数据集。
例如,TensorFlow 的 I/O 库是一个包含文件系统和文件格式的集合,这些在 TensorFlow 的内置支持中是不可用的。我们可以从 URL 加载像 MNIST 这样的数据集,以便直接访问传递给 tfio.IODataset.from_mnist() API 调用的数据集文件,如下面的列表所示。这种能力归功于 TensorFlow (github.com/tensorflow/io) I/O 库对 HTTP 文件系统的固有支持,消除了在本地目录中下载和保存数据集的需要。
列表 2.3 使用 TensorFlow I/O 加载 MNIST 数据集
> import tensorflow_io as tfio ❶
>
> d_train = tfio.IODataset.from_mnist( ❷
'http:/ /yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz',
'http:/ /yann.lecun.com/exdb/mnist/train-labels-idx1-ubyte.gz')
❶ 加载 TensorFlow I/O 库
❷ 从 URL 加载 MNIST 数据集以直接访问数据集文件,无需通过 HTTP 文件系统支持下载
对于可能存储在分布式文件系统或数据库中的大型数据集,一些 API 可以在不一次性下载所有内容的情况下加载它们,这可能会引起内存或磁盘相关的问题。为了演示目的,在此不深入细节,以下列表展示了如何从 PostgreSQL 数据库加载数据集(www.postgresql.org)。(您需要设置自己的 PostgreSQL 数据库并提供运行此示例所需的环境变量。)
列表 2.4 从 PostgreSQL 数据库加载数据集
> import os ❶
> import tensorflow_io as tfio ❷
>
> endpoint="postgresql://{}:{}@{}?port={}&dbname={}".format( ❸
os.environ['TFIO_DEMO_DATABASE_USER'],
os.environ['TFIO_DEMO_DATABASE_PASS'],
os.environ['TFIO_DEMO_DATABASE_HOST'],
os.environ['TFIO_DEMO_DATABASE_PORT'],
os.environ['TFIO_DEMO_DATABASE_NAME'],
)
>
> dataset = tfio.experimental.IODataset.from_sql( ❹
query="SELECT co, pt08s1 FROM AirQualityUCI;",
endpoint=endpoint)
> print(dataset.element_spec) ❺
{
'co': TensorSpec(shape=(), dtype=tf.float32, name=None),
'pt08s1': TensorSpec(shape=(), dtype=tf.int32, name=None)
}
❶ 加载 Python 的内置 OS 库,用于加载与 PostgreSQL 数据库相关的环境变量
❷ 加载 TensorFlow I/O 库
❸ 构建访问 PostgreSQL 数据库的端点
❹ 从数据库中的 AirQualityUCI 表中选取两列并实例化一个 tf.data.Dataset 对象
❺ 检查数据集的规范,例如每列的形状和数据类型
现在,让我们回到我们的场景。在这种情况下,假设 TensorFlow 不提供像 TensorFlow I/O 这样的 API 来处理大数据集。鉴于我们没有太多的空闲内存,我们不应直接将整个 Fashion-MNIST 数据集加载到内存中。让我们假设我们想要在数据集上执行的数学运算可以在整个数据集的子集上执行。然后我们可以将数据集划分为更小的子集(mini-batches),加载每个示例图像的 mini-batch,对每个批次执行昂贵的数学运算,并在每个模型训练迭代中仅使用一个 mini-batch 的图像。
如果第一个 mini-batch 包含图 2.4 中的 19 个示例图像,我们可以首先对这些图像执行卷积或其他复杂的数学运算,然后将转换后的图像发送到机器学习模型进行模型训练。我们在同时继续模型训练的过程中重复对剩余的 mini-batches 执行相同的操作。
由于我们将数据集划分为许多小的子集或 mini-batches,我们在执行达到准确分类模型所需的复杂数学运算时避免了潜在的内存不足问题。然后我们可以通过减小 mini-batch 的大小来处理更大的数据集。这种方法称为批处理。在数据摄取过程中,批处理涉及将整个数据集中的数据记录分组到将要依次用于训练机器学习模型的批次中。
如果我们有一个包含 100 条记录的数据集,我们可以从 100 条记录中取出 50 条来形成一个批次,然后使用这个记录批次来训练模型。我们重复此批处理和模型训练过程,直到处理完剩余的记录。换句话说,我们总共制作了两个批次;每个批次包含 50 条记录,我们正在训练的模型逐个消耗这些批次。图 2.6 说明了将原始数据集分为两个批次的过程。第一个批次在时间 t0 被消耗以训练模型,第二个批次在时间 t1 被消耗。因此,我们不必一次性将整个数据集加载到内存中;相反,我们是按顺序,批次批次地消耗数据集。

图 2.6 数据集被分为两个批次。第一个批次在时间 t0 被消耗以训练模型,第二个批次在时间 t1 被消耗。
这种批处理模式可以总结为以下列表中的伪代码,其中我们持续尝试从数据集中读取下一个批次并训练模型,直到没有更多批次为止。
列表 2.5 批处理的伪代码
batch = read_next_batch(dataset) ❶
while batch is not None:
model.train(batch) ❷
batch = read_next_batch(dataset) ❸
❶ 从数据集中读取下一个批次
❷ 使用这个批次训练模型
❸ 在训练当前批次之后读取下一个批次
当我们想要处理和准备大型数据集以进行模型训练时,我们可以应用批处理模式。当我们使用的框架只能处理内存中的数据集时,我们可以处理整个大型数据集的小批次,以确保每个批次都可以在有限的内存中处理。此外,如果数据集被分为批次,我们可以对每个批次进行顺序的密集计算,而不需要大量的计算资源。我们将在第 9.1.2 节中应用此模式。
2.3.3 讨论
在执行批处理时,需要考虑其他因素。这种方法只有在所执行的数学运算或算法可以在整个数据集的子集上以流式方式执行时才是可行的。如果一个算法需要了解整个数据集,例如整个数据集中某个特征的求和,那么批处理就不再是一个可行的方案,因为无法从整个数据集的子集中获得此类信息。
此外,机器学习研究人员和从业者经常在 Fashion-MNIST 数据集上尝试不同的机器学习模型,以获得性能更好、更准确的模型。如果一个算法希望为每个类别至少看到 10 个示例来初始化其模型参数,例如,批处理不是一种合适的方法。不能保证每个小批次都包含至少来自每个类别的 10 个示例,尤其是在批次大小较小时。在极端情况下,批次大小为 10,并且很难在所有批次中至少看到每个类别的至少一个图像。
另一个需要记住的是,机器学习模型的批大小,尤其是对于深度学习模型,强烈依赖于资源分配,这使得在共享资源环境中预先决定特别困难。此外,机器学习作业可以高效使用的资源分配不仅取决于正在训练的模型的结构,还取决于批大小。这种资源和批大小之间的相互依赖关系,为机器学习从业者配置作业以实现高效执行和资源使用创造了一个复杂的考虑因素网络。
幸运的是,有一些算法和框架可以消除手动调整批量大小的需求。例如,AdaptDL (github.com/petuum/adaptdl) 提供了自动批量大小的缩放功能,使得在不需要手动调整批量大小的努力下,能够进行高效的分布式训练。它在训练过程中测量系统性能和梯度噪声尺度,并自适应地选择最有效的批大小。图 2.7 比较了自动和手动调整批大小对 ResNet18 模型整体训练时间的影响 (arxiv.org/abs/1512.03385)。

图 2.7 自动和手动调整批大小对 ResNet18 模型整体训练时间影响的比较(来源:Petuum,许可协议为 Apache License 2.0)
批处理模式提供了一种很好的方法来提取整个数据集的子集,以便我们可以顺序地提供批次进行模型训练。对于可能不适合单台机器的极大规模数据集,我们需要其他技术。下一节将介绍一种新的模式,该模式解决了这些挑战。
2.3.4 练习
-
我们是并行还是顺序地使用批次来训练模型?
-
如果我们使用的机器学习框架无法处理大型数据集,我们能否使用批处理模式?
-
如果一个机器学习模型需要知道整个数据集特征的均值,我们还能使用批处理模式吗?
2.4 分片模式:在多台机器之间分割极大规模的数据集
第 2.3 节介绍了 Fashion-MNIST 数据集,其压缩版本在磁盘上仅占用 30 MB。尽管一次性将整个数据集加载到内存中是微不足道的,但加载用于模型训练的大型数据集却具有挑战性。
第 2.3 节中讨论的批处理模式通过将整个数据集的数据记录分组到将要用于顺序训练模型的批次中,来解决这一问题。当我们想要处理和准备大型数据集以进行模型训练时,无论是我们使用的框架无法处理大型数据集,还是框架的底层实现需要领域专业知识,我们都可以应用批处理模式。
假设我们手头有一个更大的数据集。这个数据集比 Fashion-MNIST 数据集大 1000 倍。换句话说,它的压缩版本在磁盘上占用 30 MB × 1,000 = 30 GB,解压缩后大约是 50 GB。这个新的数据集有 60,000 × 1,000 = 60,000,000 个训练样本。
我们将尝试使用这个更大的数据集来训练我们的机器学习模型,以便将图像分类到扩展的 Fashion-MNIST 数据集(T 恤、包等)中的类别。现在,我不会详细讨论机器学习模型的架构(第三章);相反,我将专注于其数据摄取组件。假设我们可以使用三台机器来加速任何潜在的过程。
根据我们的经验,由于数据集很大,我们可以先尝试应用批处理模式,将整个数据集分成足够小,可以加载到内存中进行模型训练的批次。假设我们的笔记本电脑有足够的资源来存储整个 50 GB 的未压缩数据集。我们将数据集分成 10 个小批次(每个 5 GB)。使用这种批处理方法,只要我们的笔记本电脑可以存储大型数据集并将它们分成批次,我们就可以处理大型数据集。
接下来,我们开始使用数据批次进行模型训练过程。在第 2.3 节中,我们按顺序训练了模型。换句话说,在下一个批次被消耗之前,机器学习模型已经完全消耗了一个批次。在图 2.8 中,第二个批次在时间 t1 被模型拟合消耗,而第一个批次在时间 t0 已经被模型完全消耗。t0 和 t1 代表这个过程中的两个连续时间点。

图 2.8 数据集被分成两个批次。第一个批次在时间 t0 被消耗以训练模型,第二个批次在时间 t1 被消耗。
2.4.1 问题
不幸的是,这种按顺序处理数据的过程可能会很慢。如果我们训练的特定模型中,每个 5 GB 的数据批次需要大约 1 小时来完成,那么在整个数据集上完成模型训练过程将需要 10 小时。换句话说,如果我们有足够的时间按批次顺序训练模型,批处理方法可能效果很好。然而,在现实世界的应用中,总是需要更高效的模型训练,而这将受到处理数据批次所需时间的影响。
2.4.2 解决方案
既然我们已经理解了仅使用批处理模式按顺序训练模型的速度较慢,我们可以做些什么来加快数据摄入部分,这将极大地影响模型训练过程?主要问题是我们需要按顺序,批次批次地训练模型。我们能否准备多个批次,然后同时将它们发送到机器学习模型进行消费?图 2.9 显示数据集被分成两个批次,每个批次同时被用于训练模型。这种方法目前还不适用,因为我们不能同时将整个数据集(两个批次)保存在内存中,但它接近解决方案。

图 2.9 数据集被分成两个批次;每个批次同时被用于训练模型。
假设我们有多个工作机器,每个机器上都包含一个机器学习模型的副本。每个副本可以消费原始数据集的一个批次;因此,工作机器可以独立消费多个批次。图 2.10 显示了多个工作机器的架构图;每个机器独立消费批次以训练其上的模型副本。

图 2.10 多个工作机器的架构图。每个工作机器独立消费批次以训练其上的模型副本。
你可能会想知道,如果多个模型副本独立消费多个不同的批次,我们将从这些模型副本中获得最终的机器学习模型,这是一个很好的问题。请放心,我将在第三章中详细介绍模型训练过程。现在,假设我们有允许多个工作机器独立消费多个数据集批次的模式。这些模式将极大地加快模型训练过程,因为顺序模型训练的性质而减慢。
注意:在第三章中,我们将使用名为收集通信模式的模式来训练位于多个工作机器上的多个模型副本。例如,集体通信模式将负责在工作机器之间通信梯度计算的更新,并保持模型副本同步。
我们如何生产那些工作机器使用的批次呢?在我们的场景中,数据集有 6000 万个训练示例,并且有 3 个工作机器可用。将数据集分成多个非重叠的子集,然后将每个子集发送到三个工作机器,就像图 2.11 所示,这个过程很简单。将大型数据集拆分成多个分散在多个机器上的小数据块的过程被称为分片,而较小的数据块被称为数据分片。图 2.11 显示了原始数据集被分片成多个非重叠的数据分片,然后被多个工作机器消费。

图 2.11 一个架构图,其中原始数据集被分割成多个非重叠的数据分片,然后由多个工作机器消费
注意:尽管我在这里介绍了分片,但这个概念并不新颖;它通常用于分布式数据库中。在分布式数据库中的分片对于解决诸如提供数据库高可用性、增加吞吐量和减少查询响应时间等扩展挑战非常有用。
分片本质上是一个水平数据分区,包含整个数据集的子集,分片也被称为水平分区。水平与垂直的区别来自于数据库的传统表格视图。数据库可以垂直分割——将不同的表列存储在不同的数据库中——或者水平分割——将同一表的行存储在多个数据库中。图 2.12 比较了垂直分区和水平分区。请注意,对于垂直分区,我们将数据库分割成列。一些列可能为空,这就是为什么我们看到图右侧分区中只有五行中的三行。

图 2.12 垂直分区与水平分区(来源:YugabyteDB,Apache License 2.0 许可)
这种分片模式可以在列表 2.6 中的伪代码中总结,其中首先,我们从其中一个工作机器(在这种情况下,是排名为 0 的工作机器)创建数据分片,然后将其发送到所有其他工作机器。接下来,在每个工作机器上,我们持续尝试读取本地将用于训练模型的下一个分片,直到没有更多分片留在本地。
列表 2.6 分片伪代码
if get_worker_rank() == 0: ❶
create_and_send_shards(dataset) ❶
shard = read_next_shard_locally() ❷
while shard is not None:
model.train(shard) ❸
shard = read_next_shard_locally() ❹
❶ 从排名为 0 的工作机器创建并发送分片到所有其他工作机器
❷ 在此工作机器上读取下一个可用的分片
❸ 使用从本地工作机器读取的最后一个分片来训练模型
❹ 完成当前分片训练后,读取下一个分片
通过使用分片模式,我们可以将极其庞大的数据集分割成多个数据分片,这些分片可以分散在多个工作机器上,然后每个工作机器独立负责消费单个数据分片。因此,我们避免了由于批处理模式导致的顺序模型训练缓慢的问题。有时,将大型数据集分割成不同大小的子集也是有用的,这样每个分片可以根据每个工作机器中可用的计算资源量运行不同的计算工作负载。我们将在第 9.1.2 节中应用此模式。
2.4.3 讨论
我们已经成功使用分片模式将一个非常大的数据集分割成多个数据分片,这些分片分布在多个工作机之间,并在添加负责独立在每个数据分片上训练模型的额外工作机时加速了训练过程。这很好,并且使用这种方法,我们可以在非常大的数据集上训练机器学习模型。
现在问题来了:如果数据集持续增长,并且我们需要将刚刚到达的新数据纳入模型训练过程中,该怎么办?在这种情况下,如果数据集已经更新,我们不得不不时地重新分片,以重新平衡每个数据分片,确保它们在不同工作机之间相对均匀地分割。
在 2.3.2 节中,我们只是简单地将数据集分为两个不重叠的分片,但不幸的是,在现实世界的系统中,这种方法并不理想,甚至可能根本不起作用。手动分片的最大挑战之一是不均匀的分片分配。数据的不均匀分布可能导致分片不平衡,一些分片过载,而另一些则相对空闲。这种不平衡可能导致涉及多个工作机的模型训练过程意外挂起,我们将在下一章中进一步讨论。图 2.13 是一个示例,其中原始数据集被分割成多个不平衡的数据分片,然后由多个工作机消费。

图 2.13 原始数据集被分割成多个不平衡的数据分片,然后由多个工作机消费。
最好避免在一个单独的数据分片中存储过多的数据,这可能会导致速度减慢和机器崩溃。当我们将数据集强制分散到过少的分片中时,这个问题也可能发生。这种方法在开发和测试环境中是可以接受的,但在生产环境中并不理想。
此外,每次我们在不断增长的数据集中看到更新时,如果使用手动分片,操作过程就变得非同小可。现在我们不得不为多个工作机进行备份,并且我们必须仔细协调数据迁移和模式更改,以确保所有分片都有相同的模式副本。
为了解决这个问题,我们可以基于算法而不是手动分片数据集来应用自动分片。图 2.14 所示的哈希分片取数据分片的关键值,生成一个哈希值。然后,生成的哈希值用于确定数据集的子集应该位于何处。使用均匀的哈希算法,哈希函数可以在不同的机器上均匀分布数据,减少前面提到的问题。此外,具有接近的分片键的数据不太可能被放置在同一个分片中。

图 2.14 哈希分片的示意图。生成一个哈希值以确定数据集的子集应位于何处。(来源:YugabyteDB,Apache License 2.0 许可)
分片模式通过将极其大的数据集分割成多个数据分片,这些分片分散在多个工作机器上来实现;然后每个工作机器负责独立消耗单个数据分片。采用这种方法,我们可以避免由于批处理模式导致的顺序模型训练的缓慢。批处理和分片模式都对模型训练过程有效;最终,数据集将被彻底迭代。然而,一些机器学习算法需要多次扫描数据集,这意味着我们可能需要执行两次批处理和分片。下一节将介绍一种加快此过程的方法。
2.4.4 练习
-
本节中引入的分片模式是使用水平分区还是垂直分区?
-
模型是从哪里读取每个分片的?
-
是否有手动分片之外的替代方案?
2.5 缓存模式
让我们回顾一下我们迄今为止学到的模式。在 2.3 节中,我们成功使用了批处理模式来处理和准备大型数据集以供模型训练,当机器学习框架无法处理大型数据集或框架的底层实现需要领域专业知识时。借助批处理,我们可以在有限的内存下处理大型数据集并执行昂贵的操作。在 2.4 节中,我们应用了分片模式将大型数据集分割成多个数据分片,这些分片分散在多个工作机器上。随着我们添加更多负责独立在每个数据分片上执行模型训练的工作机器,我们加快了训练过程。这两种模式都是很好的方法,使我们能够在单个机器上无法容纳或会减慢模型训练过程的大型数据集上训练机器学习模型。
我还没有提到的一个事实是,现代机器学习算法,例如基于树的算法和深度学习算法,通常需要多次训练。每个epoch是对我们正在训练的所有数据的完整遍历,当每个样本都已被看到一次。单个 epoch 指的是模型看到数据集中所有示例的唯一一次。在 Fashion-MNIST 数据集中,单个 epoch 意味着我们正在训练的模型已经处理并消耗了所有 60,000 个示例一次。图 2.15 显示了多 epoch 的模型训练。

图 2.15 在时间 t0、t1 等时刻,模型进行多 epoch 训练的示意图
训练这类机器学习算法通常涉及优化大量高度相互依赖的参数。实际上,可能需要大量的标记训练示例才能使模型接近最优解。在深度学习算法中,由于批量梯度下降的随机性,这个问题变得更加严重,其底层优化算法对数据有很强的需求。
不幸的是,这些算法需要的多维数据类型,例如 Fashion-MNIST 数据集中的数据,可能难以标记并且占用大量的存储空间。因此,尽管我们需要向模型提供大量数据,但可用的样本数量通常远小于优化算法达到足够好的解所需的样本数量。这些训练样本中可能包含足够的信息,但梯度下降算法需要时间来提取它。
幸运的是,我们可以通过多次遍历数据来补偿样本数量的限制。这种方法给算法提供了收敛的时间,而不需要不切实际的数据量。换句话说,我们可以训练一个足够好的模型,该模型可以多次消耗训练数据集。
2.5.1 问题:为了高效的多迭代模型训练而重新访问之前使用的数据
既然我们知道我们可以在训练数据集上对机器学习模型进行多次迭代训练,那么假设我们想在 Fashion-MNIST 数据集上这样做。如果在整个训练数据集上训练一个迭代需要 3 小时,那么如果我们想训练两个迭代,我们需要将模型训练的时间翻倍,如图 2.16 所示。在现实世界的机器学习系统中,通常需要更多的迭代次数,因此这种方法并不高效。

图 2.16 在时间 t0、t1 等多次迭代训练模型的示意图。我们每个迭代花费了 3 小时。
2.5.2 解决方案
考虑到多次迭代训练机器学习模型所需的不合理时间,我们能否做些什么来加快这个过程?对于第一个迭代,我们无法改进过程,因为这是机器学习模型第一次看到整个训练数据集。那么第二个迭代呢?我们能利用模型已经见过整个训练数据集一次的事实吗?
假设我们用来训练模型的笔记本电脑有足够的计算资源,例如内存和磁盘空间。一旦机器学习模型从整个数据集中消耗了每个训练示例,我们可以推迟回收,而是将消耗的训练示例保留在内存中。换句话说,我们正在以内存表示的形式存储训练示例的缓存,这在我们随后在后续训练迭代中再次访问时可以提供加速。
在图 2.17 中,我们在完成第一个 epoch 的模型拟合后,存储了我们用于第一个 epoch 模型训练的两个批次缓存。然后我们可以通过直接将存储在内存中的缓存提供给模型来开始第二个 epoch 的模型训练,而无需再次从数据源读取未来 epochs 的数据。

图 2.17:在时间 t0、t1 等使用缓存而不是再次从数据源读取,进行多个 epochs 的模型训练的示意图
这种缓存模式可以总结为以下列表中的伪代码。我们读取下一批数据以训练模型,然后在第一个 epoch 期间将此批次追加到初始化的缓存中。对于剩余的 epochs,我们从缓存中读取批次,然后使用这些批次进行模型训练。
列表 2.7:缓存伪代码
batch = read_next_batch(dataset) ❶
cache = initialize_cache(batch) ❷
while batch is not None: ❸
model.train(batch) ❸
cache.append(batch) ❸
batch = read_next_batch(dataset) ❸
while current_epoch() <= total_epochs: ❹
batch = cache.read_next_batch() ❹
model.train(batch) ❹
❶ 读取数据集的下一批
❷ 为此批次初始化缓存
❸ 通过迭代批次来训练模型
❹ 使用先前缓存的批次进行额外的 epochs 训练模型
如果我们对原始数据集执行了昂贵的预处理步骤,我们可以缓存处理过的数据集而不是原始数据集,从而避免再次处理数据集而浪费时间。伪代码如下所示。
列表 2.8:带有预处理的缓存伪代码
batch = read_next_batch(dataset)
cache = initialize_cache(preprocess(batch)) ❶
while batch is not None:
batch = preprocess(batch)
model.train(batch)
cache.append(batch)
batch = read_next_batch(dataset)
while current_epoch() <= total_epochs:
processed_batch = cache.read_next_batch() ❷
model.train(processed_batch) ❷
❶ 使用预处理的批次初始化缓存
❷ 从缓存中检索处理过的批次并用于模型训练
注意,列表 2.8 与列表 2.7 相似。两个细微的区别是,我们使用预处理的批次而不是原始批次初始化缓存,就像列表 2.7 中那样,并且我们从批次直接读取处理过的批次,而无需在模型训练之前再次预处理批次。
在涉及在多个 epochs 上对同一数据集进行训练的模型训练过程中,借助缓存模式,我们可以极大地加快对数据集的重新访问。缓存对于快速恢复任何故障也很有用;机器学习系统可以轻松地重新访问缓存的数据库,并继续管道中的其余过程。我们将在第 9.1.1 节中应用此模式。
2.5.3 讨论
我们已经成功使用缓存模式将缓存存储在每个工作机器的内存中,从而加快了在多个 epoch 的模型训练中访问先前使用数据的速度。如果工作机器发生故障怎么办?例如,如果由于内存不足错误而终止训练过程,我们就会丢失之前存储在内存中的所有缓存。
为了避免丢失之前存储的缓存,我们可以将缓存写入磁盘而不是存储在内存中,并持续保留直到模型训练过程仍然需要它。这样,我们可以通过使用磁盘上之前存储的训练数据缓存来轻松恢复训练过程。第三章深入讨论了如何恢复训练过程或使训练过程更具容错性。
将缓存存储在磁盘上是一个很好的解决方案。然而,需要注意的是,当我们进行顺序访问时,从内存中读取或写入大约快六倍,但当我们进行随机访问而不是从磁盘访问时,大约快 100,000 倍。随机访问内存(RAM)的访问速度以纳秒计算,而硬盘的访问速度以毫秒计算。换句话说,由于访问速度的差异,在内存中存储缓存和在磁盘上存储缓存之间存在权衡。图 2.18 提供了使用磁盘缓存的模型训练图。

图 2.18 在时间 t0、t1 等时刻使用磁盘缓存的多次迭代模型训练图
一般而言,如果我们想构建一个更可靠和容错的系统,则将缓存存储在磁盘上更可取;如果我们想拥有更高效的模型训练和数据摄取过程,则将缓存存储在内存中更可取。当机器学习系统需要从远程数据库读取时,磁盘缓存可以非常有用,而读取内存缓存比读取远程数据库快得多,尤其是在网络连接不够快和稳定的情况下。
如果数据集随着时间的推移更新并累积,就像第 2.3.3 节中描述的那样,其中每个工作机的数据分片需要重新分配和平衡,该怎么办?在这种情况下,我们应该考虑缓存的时效性,并根据具体应用定期更新它。
2.5.4 练习
-
缓存对于需要使用相同数据集进行训练或在不同数据集上多次迭代的模型训练是否有用?
-
如果数据集需要预处理,我们应该在缓存中存储什么?
-
磁盘缓存比内存缓存访问速度更快吗?
2.6 练习答案
第 2.3.4 节
-
顺序地
-
是的。这是批处理的主要用例之一。
-
否
第 2.4.4 节
-
水平分区
-
在每个工作机上本地
-
自动分片,例如哈希分片
第 2.5.4 节
-
相同数据集
-
我们应该将预处理后的批次存储在缓存中,以避免在后续迭代中再次浪费预处理时间。
-
否。一般来说,内存缓存访问速度更快。
摘要
-
数据摄取通常是机器学习系统的开始过程,负责监控任何传入的数据,并执行必要的处理步骤以准备模型训练。
-
批处理模式通过以小批量消耗数据集来帮助在内存中处理大型数据集。
-
分片模式将极其庞大的数据集准备成更小的块,这些块位于不同的机器上。
-
缓存模式通过缓存之前访问过的数据,使得在相同数据集上进行模型训练的额外轮次的数据获取更加高效。
3 分布式训练模式
本章涵盖了
-
区分传统模型训练过程与分布式训练过程
-
使用参数服务器构建无法适应单个机器的模型
-
使用集体通信模式提高分布式模型训练性能
-
处理分布式模型训练过程中出现的意外故障
上一章介绍了几种可以融入数据摄入过程的实用模式,这通常是分布式机器学习系统中的开始过程,该过程负责监控任何传入的数据并执行必要的预处理步骤以准备模型训练。
分布式训练,数据摄入过程之后的下一步,是区分分布式机器学习系统与其他分布式系统的关键因素。它是分布式机器学习系统中最关键的部分。
系统设计需要可扩展和可靠,以处理不同大小和复杂程度的数据库和模型。一些大型且复杂的模型无法适应单个机器,而一些中型模型虽然足够小可以适应单个机器,但难以提高分布式训练的计算性能。
知道在遇到性能瓶颈和意外故障时应该做什么也是至关重要的。数据集的部分可能已损坏或无法成功用于训练模型,或者依赖于分布式训练的分布式集群可能因天气条件或人为错误而出现不稳定或甚至断开连接的网络。
在本章中,我将探讨分布式训练过程中的一些挑战,并介绍一些在行业中广泛采用的既定模式。第 3.2 节讨论了训练大型机器学习模型的挑战,这些模型标记了新 YouTube 视频中的主要主题,但无法适应单个机器;它还展示了如何使用参数服务器模式克服这一困难。第 3.3 节展示了如何使用集体通信模式加快小型模型的分布式训练,并避免参数服务器和工作之间不必要的通信开销。最后一节讨论了由于数据集损坏、不稳定网络和抢占式工作机等原因导致的分布式机器学习系统的某些漏洞,以及解决这些问题的方法。
3.1 什么是分布式训练?
分布式训练 是指将数据摄入(在第二章中讨论)处理后的数据,初始化机器学习模型,然后在分布式环境中(如多个节点)使用处理后的数据进行模型训练的过程。这个过程很容易与机器学习模型的传统训练过程混淆,后者发生在单节点环境中,数据集和机器学习模型对象在同一台机器上,例如笔记本电脑。相比之下,分布式模型训练通常发生在由多个机器组成的集群中,这些机器可以并行工作,从而大大加快训练过程。
此外,在传统的模型训练中,数据集通常位于单个笔记本电脑或机器的本地磁盘上,而在分布式模型训练中,使用远程分布式数据库来存储数据集,或者数据集必须在多台机器的磁盘上进行分区。如果模型太大,无法适应单台机器,则无法使用单台机器以传统方式训练模型。从网络基础设施的角度来看,InfiniBand (wiki.archlinux.org/title/InfiniBand) 或远程直接内存访问(RDMA;www.geeksforgeeks.org/remote-direct-memory-access-rdma/) 网络通常比单个本地主机更适合分布式训练。表 3.1 提供了这些训练方法的比较。
表 3.1 机器学习模型的传统(非分布式)训练与分布式训练比较
| 传统模型训练 | 分布式模型训练 | |
|---|---|---|
| 计算资源 | 笔记本电脑或单个远程服务器 | 机器集群 |
| 数据集位置 | 单个笔记本电脑或机器上的本地磁盘 | 远程分布式数据库或多个机器磁盘上的分区 |
| 网络基础设施 | 本地主机 | InfiniBand 或 RDMA |
| 模型大小 | 足够小,可以适应单台机器 | 中等到大型 |
InfiniBand 和 RDMA
InfiniBand 是一种用于高性能计算的计算机网络通信标准。它具有高吞吐量和低延迟的数据互联功能,这对于分布式训练通常是有要求的。
RDMA 提供了从多台机器的内存中直接访问,而不涉及任何机器的操作系统。这个标准允许高吞吐量、低延迟的网络连接——这在分布式训练过程中特别有用,因为在分布式训练过程中,机器之间的通信频繁。
3.2 参数服务器模式:对 800 万 YouTube 视频中的实体进行标记
假设我们有一个名为 YouTube-8M 的数据集(research.google.com/youtube8m; 图 3.1),它包含数百万个 YouTube 视频 ID,以及来自超过 3,800 个视觉实体(如食品、汽车和音乐)的优质机器生成注释。我们希望训练一个机器学习模型,以标记模型尚未见过的 YouTube 视频的主要主题。

图 3.1 托管 YouTube-8M 数据集的网站,展示了来自超过 3,800 个视觉实体的数百万个 YouTube 视频(来源:Sudheendra Vijayanarasimhan 等人。根据非独占许可 1.0 授权)
此数据集包含粗粒度和细粒度实体。粗粒度实体是非领域专家在研究一些现有示例后可以识别的实体,而细粒度实体可以通过了解如何区分极其相似实体的领域专家识别。这些实体已经由三位评分者半自动整理并人工验证,以确保视觉可识别性。每个实体至少有 200 个相应的视频示例,平均有 3,552 个训练视频。当评分者识别视频中的实体时,他们会被提供一份指南,以使用从 1 到 5 的离散量表来评估每个实体的具体性和视觉识别度,其中 1 代表一个外行人可以轻易识别的实体(图 3.2)。

图 3.2 显示给人类评分者的问题和指南的截图,用于识别 YouTube 视频中的实体,以评估每个实体的视觉识别度(来源:Sudheendra Vijayanarasimhan 等人。根据非独占许可 1.0 授权)
在 YouTube-8M 提供的在线数据集浏览器(research.google.com/youtube8m/explore.xhtml)中,实体列表显示在左侧,每个实体的视频数量显示在实体名称旁边(图 3.3)。

图 3.3 YouTube-8M 网站提供的数据集浏览器截图,按视频数量排序实体(来源:Sudheendra Vijayanarasimhan 等人。根据非独占许可 1.0 授权)
注意,在数据集浏览器中,实体按每个实体中的视频数量排序。在图 3.3 中,最受欢迎的三个实体分别是游戏、视频游戏和车辆,分别有 415,890 到 788,288 个训练示例。最不受欢迎的实体(图中未显示)是圆柱体和灰浆,分别有 123 和 127 个训练视频。
3.2.1 问题
使用这个数据集,我们希望训练一个机器学习模型来标记模型尚未见过的新的 YouTube 视频的主要主题。对于更简单的数据集和机器学习模型来说,这个任务可能是微不足道的,但对于 YouTube-8M 数据集来说,情况肯定不是这样。这个数据集包含了从数十亿帧和音频片段中预先计算出的视听特征,所以我们不必自己计算和获取它们——这些任务通常需要很长时间,并且需要大量的计算资源。
尽管在单个 GPU 上不到一天的时间内就可以在这个数据集上训练出一个强大的基线模型,但数据集的规模和多样性可以使得对复杂音频视觉模型的深入探索成为可能,这些模型的训练可能需要几周时间。有没有什么解决方案可以高效地训练这个可能很大的模型?
3.2.2 解决方案
首先,让我们使用 YouTube-8M 网站上的数据探索器查看一些实体,并看看这些实体之间是否存在任何关系。这些实体是否无关,例如,或者它们在内容上存在某种程度的重叠?经过一些探索后,我们将对模型进行必要的调整,以考虑这些关系。
图 3.4 展示了属于宠物实体的 YouTube 视频列表。在第一行的第三个视频中,一个孩子正在和一只狗玩耍。

图 3.4 属于宠物实体的示例视频(来源:Sudheendra Vijayanarasimhan 等人,许可协议为非独占许可 1.0)
让我们看看一个类似的实体。图 3.5 展示了属于动物实体的 YouTube 视频列表,其中我们可以看到鱼类、马和熊猫等动物。有趣的是,在第五行的第三个视频中,一只猫正在被吸尘器清理。有人可能会猜测这个视频也属于宠物实体,因为如果猫被人类收养,它就可以成为宠物。

图 3.5 属于动物实体的示例视频(来源:Sudheendra Vijayanarasimhan 等人,许可协议为非独占许可 1.0)
如果我们想为这个数据集构建机器学习模型,我们可能需要在直接将模型拟合到数据集之前进行一些额外的特征工程。我们可能将这两个实体(动物和宠物)的视听特征组合成一个派生特征,因为它们提供类似的信息并存在重叠,这可以根据我们选择的特定机器学习模型来提高模型的表现。如果我们继续探索实体中现有视听特征的组合,或者执行大量的特征工程步骤,我们可能就不再能够在单个 GPU 上不到一天的时间内训练出机器学习模型。
如果我们使用深度学习模型而不是需要大量特征工程和数据集探索的传统机器学习模型,模型本身会学习特征之间的底层关系,例如相似实体的视听特征。模型架构中的每一层神经网络都由权重和偏置的向量组成,代表一个经过训练的神经网络层,在训练迭代过程中,随着模型从数据集中获取更多知识,这些层会得到更新。
如果我们只使用 3,862 个实体中的 10 个,我们就可以构建一个 LeNet 模型(图 3.6),将新的 YouTube 视频分类为 10 个选定实体之一。从高层次来看,LeNet 由一个包含两个卷积层的卷积编码器和一个包含三个全连接层的密集块组成。为了简化,我们假设视频的每个单独帧是一个 28 × 28 的图像,并且它将通过各种卷积和池化层进行处理,这些层学习视听特征和实体之间的底层特征映射。

图 3.6 LeNet 模型架构,可用于将新的 YouTube 视频分类为 10 个选定实体之一。(来源:Aston Zhang 等人。根据 Creative Commons Attribution-ShareAlike 4.0 国际公共许可证授权)
LeNet 的简要历史
LeNet (en.wikipedia.org/wiki/LeNet) 是最早发布的卷积神经网络(CNNs;en.wikipedia.org/wiki/Convolutional_neural_network)之一,因其计算机视觉任务上的性能而受到广泛关注。它由 AT&T Bell Labs 的研究员 Yann LeCun 提出,用于识别图像中的手写数字。在经过十年的研究和发展后,1989 年,LeCun 发表了第一篇成功通过反向传播训练 CNNs 的研究。
当时,LeNet 取得了与支持向量机(监督机器学习算法中的主导方法)相匹配的卓越成果。
实际上,那些学习到的特征图包含与模型相关的参数。这些参数是作为该层模型表示的权重和偏置使用的数值向量。对于每个训练迭代,模型将 YouTube 视频中的每一帧作为特征,计算损失,然后更新那些模型参数以优化模型的目标,从而使特征和实体之间的关系能够更紧密地建模。
不幸的是,这个训练过程很慢,因为它涉及到更新不同层的所有参数。我们有两个潜在的解决方案来加速训练过程。
让我们来看看第一种方法。这里我们想要做一个假设,我们将在讨论更好的方法时将其移除。让我们假设模型不是太大,我们可以使用现有资源来拟合整个模型,没有任何内存不足或磁盘错误的可能性。
在这种情况下,我们可以使用一台专用服务器来存储所有 LeNet 模型参数,并使用多台工作机来分配计算工作负载。图 3.7 展示了架构图。

图 3.7 单参数服务器的机器学习训练组件
每个工作节点处理数据集的特定部分来计算梯度,然后将结果发送到专用服务器以更新 LeNet 模型参数。因为工作节点使用隔离的计算资源,它们可以在无需通信的情况下异步执行繁重的计算。因此,如果我们忽略节点间消息传递等成本,仅通过引入额外的工人节点就实现了大约三倍的速度提升。
负责存储和更新模型参数的专用单服务器被称为参数服务器。我们通过整合参数服务器模式设计了一个更高效的分布式机器学习训练系统。
接下来是现实世界的挑战。深度学习模型通常很复杂;可以在基线模型之上添加具有自定义结构的额外层。这些复杂模型通常由于额外层中大量模型参数而占用大量磁盘空间。为了满足成功训练所需的内存占用要求,需要大量的计算资源。如果模型很大,而我们无法将所有参数都拟合到一个参数服务器上,那会怎样呢?
第二种解决方案可以解决这种情况下的挑战。我们可以引入额外的参数服务器,每个服务器负责存储和更新特定的模型分区。每个工作节点负责处理数据集的特定部分以更新模型分区的模型参数。
图 3.8 展示了使用多个参数服务器构建此模式的架构图。此图与图 3.7 不同,图 3.7 中只有一个服务器存储了所有 LeNet 模型参数,并通过工作机器分配计算工作负载。每个工作节点处理数据集的一个子集,执行每个神经网络层所需的计算,然后将计算出的梯度发送到更新存储在参数服务器中的一个模型分区。请注意,由于所有工作节点都以异步方式执行计算,因此每个工作节点用于计算梯度的模型分区可能不是最新的。为了保证每个工作节点使用的模型分区或每个参数服务器存储的模型分区是最新的,我们不得不在节点之间不断拉取和推送模型更新。

图 3.8 带有多个参数服务器的机器学习训练组件
在参数服务器的帮助下,我们可以有效地解决构建机器学习模型以标记模型尚未见过的 YouTube 新视频主要主题的挑战。图 3.9 显示了未用于模型训练的 YouTube 视频列表,由训练好的机器学习模型标记为飞机主题。即使模型太大而无法适应单个机器,我们也能有效地训练模型。请注意,尽管参数服务器模式在这种情况下可能很有用,但它特别设计用于训练具有大量参数的模型。

图 3.9 列出了未用于模型训练的新 YouTube 视频,标记为飞机主题(来源:Sudheendra Vijayanarasimhan 等人,许可协议为非独占许可 1.0)
3.2.3 讨论
前一节介绍了参数服务器模式,并展示了如何使用它来解决 YouTube-8M 视频识别应用中的潜在挑战。尽管参数服务器模式在模型太大而无法适应单个机器时很有用,而且这些模式似乎是对挑战的直接方法,但在实际应用中,我们仍然需要做出决策,以使分布式训练系统高效。
机器学习研究人员和 DevOps 工程师经常难以确定不同机器学习应用中参数服务器数量和工作节点数量的良好比例。从工作节点向参数服务器发送计算出的梯度存在非平凡的通信成本,以及拉取和推送最新模型分区更新的成本。如果我们发现模型变得越来越大,并向系统中添加过多的参数服务器,系统最终将花费大量时间在节点之间进行通信,而在神经网络层之间进行计算的时间却很少。
第 3.3 节更详细地讨论了这些实际挑战。该节介绍了一种模式,以解决这些挑战,这样工程师就不再需要花费时间调整不同类型模型的工作者和参数服务器的性能。
3.2.4 练习
-
如果我们想在单台笔记本电脑上使用多个 CPU 或 GPU 训练模型,这个过程是否被认为是分布式训练?
-
增加工人或参数服务器数量会有什么结果?
-
我们应该为参数服务器分配哪些类型的计算资源(如 CPU、GPU、内存或磁盘),以及应该分配多少这类资源?
3.3 集体通信模式
第 3.2.2 节介绍了参数服务器模式,当模型太大而无法适应单个机器时,该模式非常有用,例如,我们需要构建一个系统来标记 800 万 YouTube 视频中的实体。尽管我们可以使用参数服务器来处理具有大量参数的极其大型和复杂的模型,但将此模式纳入高效分布式训练系统的设计并非易事。
第 3.2.3 节指出,支持数据科学家或分析师分布式机器学习基础设施的 DevOps 工程师,往往很难确定不同机器学习应用中参数服务器数量和工作者数量之间的良好比例。假设在我们的机器学习系统的模型训练组件中有三个参数服务器和三个工作者,如图 3.10 所示。所有三个工作者异步执行密集计算,然后将计算出的梯度发送到参数服务器以更新模型参数的不同分区。

图 3.10 显示了一个由三个参数服务器和三个工作节点组成的分布式模型训练组件
在现实中,工作节点和参数服务器并不提供一对一的映射,尤其是当工作节点数量与参数服务器数量不同时。换句话说,多个工作者可能向同一子集的参数服务器发送更新。现在假设有两个工作者同时完成了梯度的计算,并且他们都想更新存储在相同参数服务器上的模型参数(图 3.11)。

图 3.11 显示,两个工作节点已经完成了梯度的计算,并希望同时将更新推送到第一个参数服务器。
因此,两个工人正在互相阻塞,无法同时将梯度发送到参数服务器。换句话说,来自两个工作节点的梯度不能同时被同一个参数服务器接受。
3.3.1 问题:当参数服务器成为瓶颈时提高性能
在这种情况下,只有两个工作者在向同一个参数服务器发送梯度时相互阻塞,这使得及时收集计算出的梯度变得困难,并需要一种策略来解决阻塞问题。不幸的是,在包含参数服务器的现实世界分布式训练系统中,多个工作者可能同时发送梯度;因此,我们必须解决许多通信阻塞。
当工作者数量与参数服务器数量之间的比例不理想时,例如,许多工作者同时向同一个参数服务器发送梯度。问题变得更糟,最终,不同工作者或参数服务器之间的通信阻塞成为瓶颈。有没有办法防止这个问题?
3.3.2 解决方案
在这种情况下,两个工作者需要找出一种继续的方法。他们必须协调,决定哪个工作者将首先采取下一步,然后轮流向该特定参数服务器发送计算出的梯度。此外,当一个工作者完成向该参数服务器发送梯度以更新模型参数后,该参数服务器开始将更新的模型分区发送回该工作者。因此,工作者拥有最新的模型以进行微调,因为它接收传入的数据。如果同时,另一个工作者也在向该参数服务器发送计算出的梯度,如图 3.12 所示,将发生另一个阻塞通信,工作者需要再次协调。

图 3.12 一个工作者正在拉取更新,而另一个工作者正在将更新推送到相同的参数服务器。
这次,不幸的是,协调可能不容易解决,因为试图发送计算出的梯度的工作者在计算梯度时可能没有使用最新的模型。当模型版本之间的差异较小时,这种情况可能还可以,但最终,它可能对训练模型的统计性能造成巨大差异。
如果每个参数服务器存储不同的模型分区不均匀——也许第一个参数服务器存储了三分之二的模型参数,如图 3.13 所示——使用这个过时的模型分区计算出的梯度将对最终训练模型产生巨大影响。在这种情况下,我们可能希望丢弃计算出的梯度,让其他工作者将更新的梯度发送到参数服务器。

图 3.13 不平衡模型分区的一个例子,其中第一个参数服务器包含整个模型参数集的三分之二。
现在又出现了一个挑战。如果我们认为丢失的梯度是过时的,并且它们是从整个训练数据的大部分计算出来的,那么使用最新的模型分区重新计算它们可能需要很长时间(如图 3.14 所示)?在这种情况下,我们可能希望保留这些梯度,以免浪费太多时间重新计算它们。

图 3.14 第二个工人正在尝试推动从训练数据的一半计算得到的梯度。
在现实世界的具有参数服务器的分布式机器学习系统中,我们可能会遇到许多无法完全解决的问题和挑战。当这些情况发生时,我们必须考虑协调和权衡方法。随着工作节点和参数服务器的数量增加,在节点之间和参数服务器之间拉取和推送模型参数所需的协调和通信成本变得相当大。系统最终会在节点之间花费大量时间进行通信,而在神经网络层之间进行少量计算。
尽管我们可能对将不同比例和计算资源应用于参数服务器和工作节点的权衡和性能差异有丰富的经验,但调整到一个完美的系统仍然似乎反直觉且耗时。在某些情况下,一些工作节点或参数在训练过程中失败,或者网络变得不稳定,当节点在推送和拉取更新时,会导致问题。换句话说,由于我们缺乏专业知识或可用时间来处理底层分布式基础设施,参数服务器模式可能不适合特定的用例。
对于这个问题有没有什么替代方案?参数服务器模式可能是大型模型少数几个好的选择之一,但为了简单和演示目的,让我们假设模型大小不会改变。整个模型足够小,可以放在单个机器上。换句话说,每台机器都有足够的磁盘空间来存储模型。
在这个假设下,如果我们只想提高分布式训练的性能,参数服务器之外的替代方案会是什么?没有参数服务器,我们只有工作节点,每个节点存储整个模型参数集的一个副本,如图 3.15 所示。

图 3.15 仅包含工作节点的分布式模型训练组件。每个工作节点存储整个模型参数集的一个副本,并消费数据分区来计算梯度。
在这种情况下,我们如何进行模型训练?回想一下,每个工作者消耗一些数据部分并计算更新存储在本工作者节点上的模型参数所需的梯度。当所有工作者节点成功完成其梯度计算后,我们需要聚合所有梯度,并确保每个工作者的整个模型参数集基于聚合梯度进行更新。换句话说,每个工作者应存储同一更新模型的副本。我们如何聚合所有梯度?
我们已经熟悉了将梯度从一个节点发送到另一个节点的过程,例如,将工作者节点计算出的梯度发送到参数服务器以更新特定模型分区的模型参数。通常,这个过程被称为点对点通信(图 3.16)。没有其他进程参与。

图 3.16 展示了两个进程之间进行点对点通信的示例,数据在这两个进程之间进行传输。请注意,没有其他进程参与。
在这种情况下,点对点通信效率不高。只有工作者节点参与,我们需要对所有工作者的结果执行某种聚合操作。幸运的是,我们可以使用另一种类型的通信。集体通信允许在组中跨越所有进程的通信模式,该组由所有进程的子集组成。图 3.17 说明了单个进程与由三个其他进程组成的组之间的集体通信。在这种情况下,每个工作者节点携带梯度,并希望将它们发送到包括其他工作者节点在内的一个组,以便所有工作者节点都能获得每个工作者的结果。

图 3.17 展示了单个进程与由三个其他进程组成的组之间的集体通信示例
对于我们的机器学习模型,我们在将聚合结果发送给所有工作者之前,通常会对所有接收到的梯度执行某种聚合操作。这种聚合类型被称为reduce 函数,它涉及将一组数字转换成更小的数字集。reduce 函数的例子包括求和、最大值、最小值或平均值——在我们的情况下,是从所有工作者接收到的梯度。
图 3.18 说明了 reduce 操作。进程组中每个进程的向量 v0、v1 和 v2 通过 reduce 操作与第一个进程合并。

图 3.18 展示了使用求和作为 reduce 函数的 reduce 操作示例
当梯度以分布式方式减少时,我们将减少后的梯度发送到所有工作节点,以便它们处于同一页面上,并可以以相同的方式更新模型参数,确保它们具有完全相同的模型。这种操作称为广播操作,通常用于执行集体通信。图 3.19 说明了向进程组中的每个进程发送值的广播操作。


这里 reduce 和广播操作的组合称为allreduce,它基于指定的 reduce 函数减少结果,然后将减少后的结果分布到所有进程——在我们的情况下,分布到所有工作节点,以便每个工作节点上存储的模型完全相同且是最新的(图 3.20)。当我们完成一轮 allreduce 操作后,我们通过向更新的模型提供新数据,计算梯度,并再次执行 allreduce 操作来收集所有工作节点的梯度以更新模型,开始下一轮操作。

图 3.20:一个全量减少操作的示例,该操作在每个组进程上减少结果,然后将结果发送到组中的每个进程
让我们休息一下,看看我们取得了什么成果。我们已经成功使用了集体通信模式,它利用了底层网络基础设施,来执行多个工作节点之间的梯度通信的全量减少操作,并允许我们以分布式方式训练一个中等规模的机器学习模型。因此,我们不再需要参数服务器;因此,参数服务器和工作节点之间没有通信开销。集体通信模式在机器学习系统中很有用,在分布式和并行计算系统中也很有用,在这些系统中,并发应用于计算和通信原语,如广播和减少,对于不同节点之间的通信至关重要。我们将在第 9.2.2 节中应用这种模式。
3.3.3 讨论
当我们构建的机器学习模型不是很大时,集体通信模式是参数服务器的一个很好的替代方案。因此,参数服务器和工作节点之间没有通信开销,也就不再需要花费大量精力去调整工作节点和参数服务器之间的比例。换句话说,我们可以轻松地添加工作节点来加速模型训练过程,而不用担心性能下降。
尽管如此,有一个潜在问题值得提及。在我们通过应用 allreduce 操作引入集体通信模式后,每个工作者都需要与其所有对等工作者通信,如果工作者数量变得很大,这可能会减慢整个训练过程。实际上,集体通信依赖于网络基础设施的通信,而我们尚未在 allreduce 操作中充分利用这些好处。
幸运的是,我们可以使用更好的集体通信算法来更有效地更新模型。一个例子是ring-allreduce算法。其过程与 allreduce 操作类似,但数据以环形方式传输,而不进行 reduce 操作。每个N个工作者只需要与其两个对等工作者通信 2 * (N - 1)次,以完全更新所有模型参数。换句话说,这个算法是带宽最优的;如果聚合的梯度足够大,它将最优地使用底层网络基础设施。
参数服务器模式和集体通信模式使分布式训练可扩展且高效。然而,在实践中,任何工作者或参数服务器可能由于资源不足而无法启动,也可能在分布式训练过程中失败。第 3.4 节介绍了有助于这些情况并使整个分布式训练过程更可靠的模式。
3.3.4 练习
-
阻塞通信是否只发生在工作者之间?
-
工作者是以异步还是同步方式更新存储在他们那里的模型参数?
-
你能否用其他集体通信操作的组合来表示 allreduce 操作?
3.4 弹性性和容错模式
参数服务器模式和集体通信模式都能使我们扩展分布式模型训练过程。参数服务器对于处理不适合单台机器的大型模型可能很有用;一个大型模型可以被分割并存储在多个参数服务器上,而单个工作者可以执行繁重的计算并异步更新模型参数的各个分区。然而,当我们使用参数服务器时观察到过多的通信开销,我们则可以使用集体通信模式来加速中等规模模型的训练过程。
假设我们的分布式训练组件设计良好;可以高效地训练机器学习模型;并且可以使用参数服务器和集体通信等模式处理不同类型模型的请求。有一点值得提及的是,分布式模型训练是一个长期运行的任务,通常持续数小时、数天甚至数周。像所有其他类型的软件和系统一样,这个长期运行的任务容易受到意外干预的影响。由于模型训练是一个长期过程,它可能随时受到内部或外部干预的影响。以下是一些在分布式模型训练系统中经常发生的干预示例:
-
数据集的部分损坏或无法成功用于训练模型。
-
分布式训练模型所依赖的分布式集群可能会因为天气条件或人为错误而遇到不稳定或断开连接的网络。
-
一些参数服务器或工作节点被抢占;它们依赖的计算资源被重新安排用于具有更高优先级的任务和节点。
3.4.1 问题:处理有限计算资源下的意外故障
当意外干预发生时,如果不采取行动解决它们,问题开始累积。在前一节的第一个例子中,所有工作节点使用相同的逻辑来消费数据以适应模型;当他们看到训练代码无法处理的损坏数据时,最终都会失败。在第二个例子中,当网络不稳定时,参数服务器和工作节点之间的通信会挂起,直到网络恢复。在第三个例子中,当参数服务器或工作节点被抢占时,整个训练过程被迫停止,导致不可恢复的故障。我们应该怎么做来帮助分布式训练系统在这些情况下恢复?我们有没有防止意外故障的方法?
3.4.2 解决方案
让我们来看看第一种情况。假设训练过程遇到一批损坏的数据。在图 3.21 中,YouTube-8M 数据集中的一些视频在从原始来源下载后,被第三方视频编辑软件意外修改。第一个工作节点正在尝试读取这些数据部分以供模型使用。之前初始化的机器学习模型对象无法用编辑过的和不兼容的视频数据来喂养。

图 3.21 工作节点遇到正在编辑的新批次训练数据,无法成功消费。
当这种情况发生时,训练过程遇到意外故障:现有的代码不包含处理编辑或损坏数据集的逻辑。换句话说,我们需要修改分布式模型训练逻辑以处理这种情况,然后从头开始重新训练模型。
让我们再次开始分布式训练过程,看看是否一切正常。我们可以跳过我们发现已损坏的数据批次,并继续使用剩余数据的下一个批次来训练机器学习模型。
不幸的是,在用一半的数据训练了数小时后,我们意识到新批次的数据消耗速度比以前慢得多。经过一番调查并与 DevOps 团队沟通后,我们发现由于我们数据中心之一的一个风暴,网络变得极其不稳定——这是之前提到的第二种情况。如果我们的数据集驻留在远程机器上而不是下载到本地机器上,如图 3.22 所示,训练过程将停滞等待与远程数据库建立成功的连接。在等待期间,我们应该检查点(存储)当前训练的模型参数并暂停训练过程。然后,当网络再次稳定时,我们可以轻松地恢复训练过程。

图 3.22 一个工人在从远程数据库获取数据时遇到了一个不稳定的网络。
不稳定的网络是否有其他影响?我们忽略了一个事实:我们也依赖网络在工人和参数服务器节点之间进行通信,以发送计算出的梯度并更新模型参数。回想一下,如果集合并行通信模式被整合,训练过程是同步的。换句话说,一个工人的通信会阻塞其他工人的通信;我们需要从所有工人那里获取所有梯度来聚合结果以更新模型参数。如果至少有一个工人在通信中变慢,级联效应最终会导致训练过程停滞。
在图 3.23 中,同一进程组中的三个工作进程正在执行 allreduce 操作。由于底层分布式集群遇到的不稳定网络,其中两个通信变得缓慢。结果,依赖于缓慢通信的两个进程没有及时接收到一些值(用问号表示),整个 allreduce 操作直到所有值都接收完毕才停止。

图 3.23 由于不稳定网络导致通信缓慢,整个训练过程被阻塞的全 reduce 过程
我们能做些什么来继续训练而不会受到单个节点网络性能下降的影响?在这种情况下,首先,我们可以放弃那些网络连接缓慢的两个工作进程;然后我们可以放弃当前的 allreduce 操作。鉴于集体通信模式的特点,剩余的工作者仍然有完全相同的模型副本,因此我们可以通过重建一个新的工作进程组(由剩余的工作者组成)并再次执行 allreduce 操作来继续训练过程。
这种方法也可以处理某些工作节点被抢占的情况,它们的计算资源被重新安排到更高优先级的任务和节点。当这些工作节点被抢占时,我们重建工作进程组,然后执行 allreduce 操作。这种方法使我们能够在意外故障发生时避免浪费资源从头开始训练模型。相反,我们可以从暂停的地方继续训练过程,并使用我们已分配计算资源的现有工作者。如果我们有额外的资源,我们可以轻松地添加工作者,然后重建工作进程组以更有效地训练。换句话说,我们可以轻松地扩展和缩小分布式训练系统,使整个系统在可用资源方面具有弹性。许多其他分布式系统也应用同样的想法,以确保现有的系统既可靠又可扩展。
3.4.3 讨论
我们已经成功继续并恢复了分布式训练过程,而没有浪费我们从每个工作者计算梯度的资源。如果我们使用参数服务器而不是仅与工作者进行集体通信的分布式训练,会怎样呢?
回想一下,当使用参数服务器时,每个参数服务器存储一个包含模型参数完整集合子集的模型分区。如果我们需要放弃任何工作者或参数服务器,例如,当某些通信由于某个参数服务器上的不稳定网络而失败或卡住,或者当工作者被抢占时,我们需要在失败的节点上检查点模型分区,然后将模型分区重新分区到仍然存活的服务器上。
在现实中,仍然存在许多挑战。我们如何检查点模型分区,并将它们保存在哪里?我们应该多久检查点一次,以确保它们尽可能新?
3.4.4 练习
-
如果将来发生任何故障,在检查点中保存最重要的东西是什么?
-
当我们放弃那些卡住或无法在没有时间制作模型检查点的情况下恢复的工作者时,如果我们使用集体通信模式,我们应该在哪里获取最新的模型?
3.5 练习答案
第 3.2.4 节
-
不,因为训练是在单个笔记本电脑上进行的。
-
系统最终会在节点之间花费大量时间进行通信,而在神经网络层之间进行计算的时间却很少。
-
由于参数服务器不执行重计算,我们需要更多的磁盘空间来存储大型模型分区,并且需要较少的 CPU/GPU/内存。
第 3.3.4 节
-
不,它们也出现在工作节点和参数服务器之间。
-
异步
-
您使用 reduce 操作,然后是 broadcast 操作。
第 3.4.4 节
-
最新的模型参数
-
在集体通信模式中,剩余的工作节点仍然拥有相同的模型副本,我们可以使用它来继续训练。
摘要
-
分布式模型训练与传统的模型训练过程不同,这取决于数据集的大小和位置、模型的大小、计算资源以及底层网络基础设施。
-
我们可以使用参数服务器来构建大型和复杂的模型,将模型参数的分区存储在每个服务器上。
-
如果工作节点和参数服务器之间的通信出现瓶颈,我们可以切换到集体通信模式来提高小型或中型模型的分布式模型训练性能。
-
在分布式模型训练过程中,可能会发生意外故障,我们可以采取各种方法来避免浪费计算资源。
4 模型服务模式
本章涵盖了
-
使用模型服务生成对新数据的预测或推理,这些数据是使用之前训练好的机器学习模型
-
处理模型服务请求并使用复制的模型服务服务实现水平扩展
-
使用分片服务模式处理大量的模型服务请求
-
评估模型服务系统和事件驱动设计
在上一章中,我们探讨了分布式训练组件中涉及的一些挑战,并介绍了几种可以集成到该组件中的实用模式。分布式训练是分布式机器学习系统中最关键的部分。例如,我们看到了在训练非常大的机器学习模型时遇到的挑战,这些模型用于标记新 YouTube 视频中的主要主题,但无法在一个单一机器上运行。我们探讨了如何克服使用参数服务器模式的困难。我们还学习了如何使用集体通信模式来加速较小模型的分布式训练,并避免参数服务器和工作节点之间不必要的通信开销。最后但同样重要的是,我们讨论了由于数据集损坏、网络不稳定和抢占工作机器等原因,在分布式机器学习系统中经常看到的一些漏洞,以及我们如何解决这些问题。
模型服务是在我们成功训练了一个机器学习模型之后的下一步。它是分布式机器学习系统中的关键步骤之一。模型服务组件需要具备可扩展性和可靠性,以处理不断增长的用户请求数量和单个请求的大小。了解在构建分布式模型服务系统时可能遇到的不同设计决策的权衡也是至关重要的。
在本章中,我们将探讨分布式模型服务系统中涉及的一些挑战,并介绍一些在工业界广泛采用的成熟模式。例如,我们将看到在处理越来越多的模型服务请求时遇到的挑战,以及我们如何通过复制的服务来实现水平扩展来克服这些挑战。我们还将讨论分片服务模式如何帮助系统处理大量的模型服务请求。此外,我们还将学习如何评估模型服务系统,并确定在现实场景中事件驱动设计是否具有益处。
4.1 什么是模型服务?
模型服务 是将之前训练好的机器学习模型加载到系统中,以生成对新输入数据的预测或推理的过程。这是在我们成功训练了一个机器学习模型之后的步骤。图 4.1 显示了模型服务在机器学习流程中的位置。

图 4.1 展示了模型服务在机器学习流程中的位置
注意,模型服务是一个通用概念,它出现在分布式和传统机器学习应用中。在传统机器学习应用中,模型服务通常是一个在本地桌面或机器上运行的单一程序,并为未用于模型训练的新数据集生成预测。用于模型服务的数据集和机器学习模型应该足够小,可以适应单个机器,并且存储在单个机器的本地磁盘上。
相比之下,分布式模型服务通常发生在机器集群中。用于模型服务的数据集和训练好的机器学习模型可以非常大,必须存储在远程分布式数据库中或在多台机器的磁盘上分区。传统模型服务和分布式模型服务系统之间的差异总结在表 4.1 中。
表 4.1 传统模型服务和分布式模型服务系统之间的比较
| 传统模型服务 | 分布式模型服务 | |
|---|---|---|
| 计算资源 | 个人笔记本电脑或单个远程服务器 | 机器集群 |
| 数据集位置 | 单个笔记本电脑或机器的本地磁盘 | 远程分布式数据库或多个机器的磁盘分区 |
| 模型与数据集大小 | 足够小,可以适应单个机器 | 中等到大型 |
构建和管理一个可扩展、可靠且高效的分布式模型服务系统,适用于不同的用例,并非易事。我们将检查几个用例以及一些可能解决不同挑战的既定模式。
4.2 复制服务模式:处理不断增长的服务请求
如您所忆,在前一章中,我们使用 YouTube-8M 数据集(research.google.com/youtube8m/)构建了一个机器学习模型,以标记模型之前未见过的视频的主要主题。YouTube-8M 数据集包含数百万个 YouTube 视频 ID,以及来自 3,800 多个视觉实体(如食物、汽车、音乐等)的优质机器生成注释。YouTube-8M 数据集中视频的截图如图 4.2 所示。

图 4.2 YouTube-8M 数据集中视频的截图。(来源:Sudheendra Vijayanarasimhan 等人。许可协议:非独占许可 1.0)
现在我们想构建一个模型服务系统,允许用户上传新视频。然后,系统将加载之前训练好的机器学习模型,以标记上传视频中出现的实体/主题。请注意,模型服务系统是无状态的,因此用户的请求不会影响模型服务结果。
系统基本上是接收用户上传的视频,并向模型服务器发送请求。模型服务器随后从模型存储中检索先前训练好的实体标注机器学习模型来处理视频,并最终生成视频中可能出现的实体。系统的高级概述如图 4.3 所示。

图 4.3 单节点模型服务系统的总体架构图
注意,这个模型服务器的初始版本仅在单台机器上运行,并按先到先得的原则响应用户的模型服务请求,如图 4.4 所示。如果只有极少数用户在测试系统,这种方法可能效果很好。然而,随着用户数量或模型服务请求的增加,用户在等待系统完成处理任何先前请求时将经历巨大的延迟。在现实世界中,这种糟糕的用户体验会立即失去用户对参与该系统的兴趣。

图 4.4 模型服务器仅在单台机器上运行,并按先到先得的原则响应用户的模型服务请求。
4.2.1 问题
系统接收用户上传的视频,然后向模型服务器发送请求。这些模型服务请求被排队,必须等待由模型服务器处理。
不幸的是,由于单节点模型服务器的特性,它只能基于先到先得的原则有效地处理有限数量的模型服务请求。随着实际应用中请求数量的增长,当用户必须等待很长时间才能收到模型服务结果时,用户体验会变差。所有请求都在等待由模型服务系统处理,但计算资源都绑定在这个单节点上。是否有比顺序处理更好的处理模型服务请求的方法?
4.2.2 解决方案
我们忽略的一个事实是,现有的模型服务器是无状态的,这意味着每个请求的模型服务结果不受其他请求的影响,机器学习模型只能处理单个请求。换句话说,模型服务器不需要保存状态来正确运行。
由于模型服务器是无状态的,我们可以添加更多服务器实例来帮助处理额外的用户请求,而不会相互干扰,如图 4.5 所示。这些额外的模型服务器实例是原始模型服务器的精确副本,但具有不同的服务器地址,并且每个处理不同的模型服务请求。换句话说,它们是模型服务的复制服务,或者简称为模型服务器副本。

图 4.5 额外的服务器实例帮助处理额外的用户请求,而不会相互干扰。
将更多机器添加到我们的系统中称为水平扩展。水平扩展系统通过添加更多副本来处理越来越多的用户或流量。与水平扩展相反的是垂直扩展,这通常是通过向现有机器添加计算资源来实现的。
类比:水平扩展与垂直扩展
你可以将垂直扩展想象为当你需要更多动力时,退役你的跑车并购买一辆赛车。虽然赛车速度快,外观惊人,但它也很昂贵,并不实用,最终,它们在耗尽燃料之前只能带你走这么远。此外,只有一个座位,汽车必须在平坦的路面上驾驶。它实际上只适合赛车。
水平扩展为你提供了额外的动力——不是通过偏爱跑车而不是赛车,而是通过添加另一种车辆到混合中。实际上,你可以将水平扩展想象为几辆车,可以一次容纳很多乘客。也许这些机器中没有一辆是赛车,但它们都不需要是——在整个车队中,你拥有你需要的所有动力。
让我们回到我们的原始模型服务系统,该系统处理用户上传的视频并向模型服务器发送请求。与我们的先前模型服务系统设计不同,现在的系统具有多个模型服务器副本,用于异步处理模型服务请求。每个模型服务器副本接收单个请求,从模型存储中检索先前训练的实体标注机器学习模型,然后处理请求中的视频以标记视频中的可能实体。
因此,我们通过向现有的模型服务系统中添加模型服务器副本,成功地扩展了我们的模型服务器。新的架构如图 4.6 所示。模型服务器副本能够同时处理许多请求,因为每个副本可以独立处理单个模型服务请求。

图 4.6 在我们通过向系统中添加模型服务器副本来扩展我们的模型服务器后,的系统架构
在新的架构中,来自用户的多个模型服务请求同时发送到模型服务器副本。然而,我们还没有讨论它们是如何被分配和处理的。例如,哪个请求正在由哪个模型服务器副本处理?换句话说,我们还没有定义请求和模型服务器副本之间的明确映射关系。
为了做到这一点,我们可以添加另一层——即负载均衡器,它负责在副本之间分配模型服务请求。例如,负载均衡器从我们的用户那里接收多个模型服务请求,然后将请求均匀地分配给每个模型服务器副本,这些副本随后负责处理单个请求,包括检索模型和在请求中的新数据上进行推理。图 4.7 说明了这个过程。

图 4.7 展示了负载均衡器如何用于在模型服务器副本之间均匀分配请求
负载均衡器使用不同的算法来决定哪个请求发送到哪个模型服务器副本。负载均衡的示例算法包括轮询、最少连接方法、哈希等。
轮询负载均衡
轮询是一种简单的技术,其中负载均衡器根据旋转列表将每个请求转发到不同的服务器副本。
虽然使用轮询算法实现负载均衡器很简单,但负载已经位于负载均衡服务器上,如果负载均衡服务器本身接收到大量需要昂贵处理请求,可能会变得危险。它可能会超出其有效工作的能力而超载。
复制服务模式为我们提供了横向扩展模型服务系统的绝佳方式。它也可以推广到任何需要处理大量流量的系统。每当单个实例无法处理流量时,引入这种模式可以确保所有流量都能等效且高效地处理。我们将在第 9.3.2 节中应用此模式。
4.2.3 讨论
现在我们已经设置了负载均衡的模型服务器副本,我们应该能够支持不断增长的用户请求,整个模型服务系统实现横向扩展。我们不仅能够以可扩展的方式处理模型服务请求,而且整个模型服务系统也变得高度可用 (mng.bz/EQBd)。高可用性是系统在超过正常时间保持协议的操作性能(通常是正常运行时间)的特征。它通常以一年内正常运行时间的百分比来表示。
例如,一些组织可能需要达到高度可用的服务级别协议,这意味着服务 99.9%的时间都在运行(称为三九可用性)。换句话说,服务每天只能有 1.4 分钟的停机时间(24 小时×60 分钟×0.1%)。在复制模型服务的帮助下,如果任何模型服务器副本崩溃或在即时实例上被抢占,剩余的模型服务器副本仍然可用并准备好处理来自用户的任何模型服务请求,这提供了良好的用户体验并使系统可靠。
此外,由于我们的模型服务器副本需要从远程模型存储检索先前训练的机器学习模型,它们除了需要处于“活跃”状态外,还需要处于“就绪”状态。构建和部署“就绪探测”对于通知负载均衡器副本已成功建立与远程模型存储的连接并准备好为用户提供模型服务请求非常重要。就绪探测有助于系统确定特定副本是否就绪。有了就绪探测,当系统因内部系统问题而未就绪时,用户不会遇到意外的行为。
复制服务模式解决了我们的水平扩展问题,防止我们的模型服务系统支持大量的模型服务请求。然而,在实际的模型服务系统中,不仅服务请求的数量增加,每个请求的大小也在增加,如果数据或有效负载很大,这个大小可能会变得非常大。在这种情况下,复制服务可能无法处理大请求。我们将在下一节中讨论这种情况,并介绍一种可以缓解问题的模式。
4.2.4 练习
-
复制的模型服务器是无状态的还是有状态的?
-
当模型服务系统中没有负载均衡器时会发生什么?
-
我们能否仅使用一个模型服务器实例就实现三个九的服务级别协议?
4.3 分片服务模式
复制服务模式有效地解决了我们的水平扩展问题,从而使我们的模型服务系统可以支持越来越多的用户请求。借助模型服务器副本和负载均衡器的帮助,我们还获得了高可用性的额外好处。
注意:每个模型服务器副本都有有限的、预先分配的计算资源。更重要的是,每个副本的计算资源量必须相同,以便负载均衡器可以正确且均匀地分配请求。
接下来,让我们想象一个用户想要上传一个需要使用模型服务器应用程序标记实体的高分辨率 YouTube 视频。尽管高分辨率视频太大,但如果模型服务器副本有足够的磁盘存储,它可能仍然可以成功上传到模型服务器副本。然而,我们无法在任何单个模型服务器副本中处理请求,因为处理这个单个大请求需要在模型服务器副本中分配更多的内存。这种对大量内存的需求通常是由于训练的机器学习模型的复杂性,因为它可能包含大量的昂贵矩阵计算或数学运算,正如我们在上一章中看到的。
例如,一个用户通过大请求将高分辨率视频上传到模型提供系统。其中一个模型服务器副本接收这个请求并成功检索先前训练的机器学习模型。不幸的是,由于负责处理此请求的模型服务器副本没有足够的内存,该模型随后无法处理请求中的大量数据。最终,我们可能在用户等待很长时间后通知他们此失败,这导致糟糕的用户体验。这种情况的示意图如图 4.8 所示。

图 4.8 一个示意图,显示由于负责处理此请求的模型服务器副本没有足够的内存,模型无法处理请求中的大量数据
4.3.1 问题:处理高分辨率视频的大模型提供请求
系统正在处理的大请求是因为用户上传的视频具有高分辨率。在先前训练的机器学习模型可能包含昂贵的数学运算的情况下,这些大视频请求无法由具有有限内存的个别模型服务器副本成功处理和提供。我们如何设计模型提供系统以成功处理高分辨率视频的大请求?
4.3.2 解决方案
考虑到我们对每个模型服务器副本的计算资源需求,我们能否通过增加每个副本的计算资源来垂直扩展,以便它可以处理像高分辨率视频这样的大请求?由于我们通过相同的数量垂直扩展所有副本,因此我们不会影响负载均衡器的工作。
由于我们不知道有多少这样的请求,所以我们不能简单地垂直扩展模型服务器副本。想象一下,只有少数用户需要处理高分辨率视频(例如,使用高端相机捕获高分辨率视频的专业摄影师),而剩余的绝大多数用户仅上传来自智能手机的视频,分辨率要小得多。因此,大多数添加到模型服务器副本上的计算资源都是闲置的,这导致资源利用率非常低。我们将在下一节检查资源利用率,但到目前为止,我们知道这种方法是不切实际的。
记得我们在第三章中介绍了参数服务器模式,它允许我们将一个非常大的模型分区?图 4.9 是我们在第三章中讨论的示意图,展示了具有多个参数服务器的分布式模型训练;大模型已经被分区,每个分区位于不同的参数服务器上。每个工作节点获取数据集的一个子集,执行每个神经网络层所需的计算,然后将计算出的梯度发送到更新存储在参数服务器中的一个模型分区。

图 4.9 展示了具有多个参数服务器的分布式模型训练,其中大模型已经被分割,每个分区位于不同的参数服务器上。
为了处理我们的大模型服务请求问题,我们可以借用同样的想法并将其应用于我们的特定场景。
我们首先将原始的高分辨率视频分割成多个单独的视频,然后每个视频由多个独立的模型服务器碎片分别处理。模型服务器碎片是从单个模型服务器实例中划分出来的,每个碎片负责处理大量请求的一个子集。
图 4.10 中的图示是分割服务模式的一个示例架构。在该图中,包含狗和小孩的高分辨率视频被分割成两个单独的视频,每个视频代表原始大请求的一个子集。其中一个分割的视频包含狗出现的那部分,另一个视频包含小孩出现的那部分。这两个分割的视频成为两个单独的请求,并由不同的模型服务器碎片独立处理。

图 4.10 展示了分割服务模式的一个示例架构,其中高分辨率视频被分割成两个单独的视频。每个视频代表原始大请求的一个子集,并由不同的模型服务器碎片独立处理。
在模型服务器碎片接收到包含原始大模型服务请求一部分的子请求后,每个模型服务器碎片随后从模型存储中检索之前训练好的实体标注机器学习模型,然后处理请求中的视频以标注视频中可能出现的实体,类似于我们之前设计的模型服务系统。一旦每个模型服务器碎片都处理了所有子请求,我们将两个子请求(即两个实体,狗和小孩)的模型推理结果合并,以获得原始大模型服务请求的高分辨率视频的结果。
我们如何将两个子请求分配给不同的模型服务器碎片?类似于我们用来实现负载均衡器的算法,我们可以使用一个分割函数,它与哈希函数非常相似,以确定模型服务器碎片列表中的哪个碎片应该负责处理每个子请求。
通常,分割函数使用哈希函数和取模(%)运算符定义。例如,hash(request) % 10 会在哈希函数的输出显著大于分割服务中的碎片数量时,返回 10 个碎片。
分割的哈希函数特性
定义分割函数的哈希函数将任意对象转换为一个表示特定碎片索引的整数。它有两个重要的特性:
-
哈希的输出对于给定的输入始终相同。
-
输出的分布总是在输出空间内均匀。
这些特性很重要,可以确保特定的请求始终由同一个分片服务器处理,并且请求在分片之间均匀分布。
分片服务模式解决了我们在构建大规模模型服务系统时遇到的问题,并提供了一种处理大型模型服务请求的极好方式。它与我们在第二章中介绍的数据分片模式类似:我们不是将分片应用于数据集,而是将分片应用于模型服务请求。当一个分布式系统为单个机器有限的计算资源时,我们可以应用此模式将计算负担卸载到多台机器上。
4.3.3 讨论
分片服务模式有助于处理大量请求,并有效地将处理大型模型服务请求的工作负载分配到多个模型服务器分片中。在考虑任何数据量超过单台机器可容纳的数据的服务时,通常很有用。
然而,与我们在上一节中讨论的复制的服务模式不同,后者在构建无状态服务时很有用,分片服务模式通常用于构建有状态的服务。在我们的情况下,我们需要维护状态或从原始大请求使用分片服务处理子请求的结果,然后将结果合并到最终响应中,以便它包含原始高分辨率视频中的所有实体。
在某些情况下,这种方法可能不起作用,因为它取决于我们如何将原始的大请求分割成更小的请求。例如,如果原始视频已经被分割成超过两个子请求,其中一些可能没有意义,因为它们不包含任何机器学习模型可以识别的完整实体。对于这种情况,我们需要额外的处理和清理合并后的结果,以移除对应用程序无用的无意义实体。
在大规模构建模型服务系统以处理大量大型模型服务请求时,复制的服务模式和分片服务模式都很有价值。然而,要将它们纳入模型服务系统,我们需要了解手头可用的计算资源,如果流量相对动态,这些资源可能不可用。在下一节中,我将介绍另一种模式,该模式专注于可以处理动态流量的模型服务系统。
4.3.4 练习
-
在处理大量请求时,垂直扩展会有帮助吗?
-
模型服务器分片是有状态的还是无状态的?
4.4 事件驱动处理模式
我们在 4.2 节中考察的复制服务模式有助于处理大量的模型服务请求,而 4.3 节中的分片服务模式可以用来处理可能不适合单个模型服务器实例的非常大的请求。尽管这些模式解决了在规模上构建模型服务系统的挑战,但它们更适合在系统启动接收用户请求之前就知道需要分配多少计算资源、模型服务器副本或模型服务器分片时使用。
现在假设我们为一家为订阅客户提供假日和活动规划服务的公司工作。我们希望提供一个新服务,该服务将使用训练好的机器学习模型来预测位于度假区的酒店每晚的价格,前提是给定日期范围和客户希望度假的具体地点。
为了提供这项服务,我们可以设计一个机器学习模型服务系统。这个模型服务系统提供了一个用户界面,用户可以在其中输入他们感兴趣的度假日期和地点范围。一旦请求发送到模型服务器,之前训练好的机器学习模型将从分布式数据库中检索出来,并处理请求中的数据(日期和地点)。最终,模型服务器将返回给定日期范围内每个地点的预测酒店价格。整个过程如图 4.11 所示。

图 4.11 预测酒店价格的模型服务系统图示
在我们对选定客户测试这个模型服务系统一年后,我们将收集足够的数据来绘制模型服务流量随时间的变化图。结果证明,人们倾向于在假期最后一刻预订假期,因此在假期前交通量突然增加,然后在假期结束后再次减少。这种流量模式的问题在于它引入了非常低的资源利用率。
在我们当前的模式服务系统架构中,分配给模型的底层计算资源始终保持不变。这种策略似乎远非最佳:在流量低峰期,我们的大部分资源都在闲置,因此被浪费了,而在流量高峰期,我们的系统难以及时响应,需要比正常情况下更多的资源来运行。换句话说,系统必须用相同的计算资源(例如,10 个 CPU 和 100GB 的内存)来应对高流量或低流量,如图 4.12 所示。

图 4.12 模型服务系统在分配等量计算资源的情况下随时间变化的流量变化。
由于我们多少知道那些节假日时期,为什么我们不相应地计划呢?不幸的是,一些事件使得预测流量激增变得困难。例如,一个大型国际会议可能计划在图 4.13 所示的某个度假胜地附近举行。这个意外事件,发生在圣诞节前,突然在该特定时间窗口增加了流量(实线)。如果我们不知道这些会议,我们就会错过在分配计算资源时应考虑的窗口。具体来说,在我们的场景中,尽管两个 CPU 和 20GB 的内存针对我们的用例进行了优化,但已不足以处理这个时间窗口内的所有资源。用户体验会很差。想象一下,所有会议参加者坐在笔记本电脑前,等待很长时间才能预订酒店房间。

图 4.13:我们的模型服务系统随时间变化的流量,为不同时间窗口分配了最佳数量的计算资源。此外,在圣诞节前发生了一个意外事件,在该特定时间窗口突然增加了流量(实线)。
换句话说,这种简单解决方案仍然不太实用和有效,因为确定分配不同数量资源的时间窗口以及每个时间窗口需要多少额外资源并不简单。我们能否想出更好的方法?
在我们的场景中,我们处理的是一个随时间变化的动态模型服务请求数量,它与节假日时间高度相关。如果我们能保证始终有足够的资源,现在暂时忘记提高资源利用率的目标会怎样?如果计算资源始终保证是充足的,我们可以确保模型服务系统在节假日季节能够处理大量流量。
4.4.1 问题:基于事件响应模型服务请求
一种简单的方法是在确定系统可能经历高流量时段的任何可能的时间窗口之前,相应地估计和分配计算资源,但这并不可行。确定高流量时段的确切日期以及每个时段所需的计算资源的确切数量并不容易。
简单地增加计算资源到始终足够多的程度也不切实际,因为我们之前关注的资源利用率仍然很低。例如,如果在某个特定时间段内几乎没有用户请求,那么我们分配的计算资源,不幸的是,大部分时间都在闲置,从而造成浪费。是否有另一种方法可以更明智地分配和使用计算资源?
4.4.2 解决方案
我们问题的解决方案是维护一个计算资源池(例如,CPU、内存、磁盘等),不仅分配给这个特定的模型服务系统,还分配给其他应用程序或分布式机器学习管道的其他组件的模型服务。
图 4.14 是一个示例架构图,其中不同的系统(例如,数据摄取、模型训练、模型选择、模型部署和模型服务)同时使用共享资源池。这个共享资源池为我们提供了足够的资源,通过预先分配历史高峰流量期间所需的资源,并在达到限制时自动扩展,来处理模型服务系统的峰值流量。因此,我们只在需要时使用资源,并且只为每个特定的模型服务请求使用所需的特定数量的资源。

图 4.14 是一个架构图,其中不同的组件(例如,数据摄取、模型训练、模型选择和模型部署)以及两个不同的模型服务系统同时使用共享资源池。实线箭头表示资源,虚线箭头表示请求。
在我们的讨论中,我只关注图中的模型服务系统,其他系统的细节在这里被忽略。此外,这里我假设模型训练组件仅利用类似类型的资源,例如 CPU。如果模型训练组件需要 GPU 或 CPU/GPU 的混合,根据具体用例,可能最好使用单独的资源池。
当我们的酒店价格预测应用的用户输入他们感兴趣的度假日期和地点范围时,请求模型的服务请求被发送到模型服务系统。在收到每个请求后,系统通知共享资源池,系统正在使用一定量的计算资源。
例如,图 4.15 显示了我们的模型服务系统随时间变化的流量,并出现了一个意外的峰值。这个意外的峰值是由于在圣诞节前举行的一个新的非常大型国际会议。这一事件突然增加了流量,但模型服务系统通过从共享资源池借用必要的资源量成功处理了流量的激增。在共享资源池的帮助下,在此意外事件期间资源利用率保持较高。共享资源池监控当前可用资源的数量,并在需要时自动扩展。

图 4.15 我们模型服务系统随时间变化的流量。在圣诞节前出现了一个意外的峰值,突然增加了流量。模型服务系统通过从共享资源池中借用必要的资源量成功处理了请求的增加。在此意外事件期间,资源利用率保持较高。
这种方法,即系统监听用户请求,仅在用户请求被提出时才响应和利用计算资源,被称为事件驱动处理。
事件驱动处理与长时间运行的服务系统
事件驱动处理与我们在前几节中查看的模型服务系统(例如,使用复制服务的系统[第 4.2 节]和分片服务模式[第 4.3 节])不同,在这些系统中,处理用户请求的服务器始终处于运行状态。这些长时间运行的服务系统对于许多在重负载下运行、在内存中保持大量数据或需要某种类型后台处理的应用程序来说效果很好。
然而,对于在非高峰期间处理请求很少或响应特定事件的应用程序,例如我们的酒店价格预测系统,事件驱动处理模式更为合适。这种事件驱动处理模式近年来随着云服务提供商开发函数即服务产品而蓬勃发展。
在我们的场景中,从我们的酒店价格预测系统发出的每个模型服务请求都代表一个事件。我们的服务系统监听此类事件,从共享资源池中利用必要的资源,并从分布式数据库中检索和加载训练好的机器学习模型,以估计指定时间/位置的酒店价格。图 4.16 是此事件驱动模型服务系统的示意图。

图 4.16 预测酒店价格的事件驱动模型服务系统示意图
使用这种事件驱动处理模式为我们提供服务系统,我们可以确保我们的系统仅使用处理每个请求所必需的资源,而不必担心资源利用和闲置。因此,系统有足够的资源来应对高峰流量,并在用户使用系统时,不会出现明显的延迟或滞后,返回预测价格。
尽管我们现在有一个足够的计算资源池,我们可以从中借用计算资源来按需处理用户请求,但我们也应该在我们的模型服务系统中建立一个防御服务拒绝攻击的机制。服务拒绝攻击中断了授权用户对计算机网络访问,通常由恶意意图引起,在模型服务系统中经常看到。这些攻击可能导致从共享资源池中意外使用计算资源,这最终可能导致依赖于共享资源池的其他服务资源稀缺。
服务拒绝攻击可能发生在各种情况下。例如,它们可能来自那些在极短的时间内意外发送大量模型服务请求的用户。开发者可能错误配置了使用我们模型服务 API 的客户端,因此它不断发送请求或在生产环境中意外启动了意外的负载/压力测试。
为了应对这些在现实应用中经常发生的情况,引入针对服务拒绝攻击的防御机制是有意义的。避免这些攻击的一种方法是通过速率限制,它将模型服务请求添加到队列中,并限制系统处理队列中请求的速率。
图 4.17 是一个流程图,显示了发送到模型服务系统的四个模型服务请求。然而,只有两个处于当前的速率限制之下,这允许最多两个并发模型服务请求。在这种情况下,模型服务请求的速率限制队列首先检查接收到的请求是否在当前的速率限制之下。一旦系统处理完这两个请求,它将继续处理队列中的剩余两个请求。

图 4.17 模型服务系统接收到的四个模型服务请求的流程图。然而,只有两个处于当前的速率限制之下,这允许最多两个并发模型服务请求。一旦系统处理完这两个请求,它将继续处理队列中的剩余两个请求。
如果我们正在向用户部署和公开模型服务 API 的 API,对于匿名访问的用户,通常也是最佳实践设置一个相对较小的速率限制(例如,每小时只允许一个请求),然后要求用户登录以获得更高的速率限制。这个系统将允许模型服务系统更好地控制和监控用户的行为和流量,以便我们可以采取必要的措施来解决任何潜在的问题或服务拒绝攻击。例如,要求登录可以提供审计,以找出哪些用户/事件对意外大量的模型服务请求负有责任。
图 4.18 展示了之前描述的策略。在图中,左侧的流程图与图 4.17 相同,其中未经身份验证的用户向模型服务系统发送了四个总模型服务请求。然而,由于当前的速率限制,只有两个可以被系统服务,因为未经身份验证用户的最大并发模型服务请求限制为两个。相反,右侧流程图中的模型服务请求全部来自经过身份验证的用户。因此,由于经过身份验证用户的最大并发请求限制为三个,模型服务系统可以处理三个请求。

图 4.18 对应用于经过身份验证和未经身份验证用户的速率限制行为的比较
速率限制根据用户是否经过身份验证而有所不同。因此,速率限制有效地控制了模型服务系统的流量,并防止了恶意拒绝服务攻击,这可能导致从共享资源池中意外使用计算资源,最终导致依赖它的其他服务的资源稀缺。
4.4.3 讨论
尽管我们已经看到事件驱动处理模式如何使我们的特定服务系统受益,但我们不应试图将此模式作为通用的解决方案。使用许多工具和模式可以帮助您开发一个分布式系统以满足独特的现实世界需求。
对于具有一致流量的机器学习应用(例如,根据预定计划定期计算模型预测)来说,事件驱动处理方法是不必要的,因为系统已经知道何时处理请求,尝试监控这种定期流量将产生过多的开销。此外,可以容忍不太准确预测的应用可以在不受到事件驱动的情况下良好运行;它们也可以重新计算并提供足够好的预测到特定的粒度级别,例如每天或每周。
事件驱动处理更适合那些系统在事先准备必要的计算资源时复杂的、具有不同流量模式的应用。使用事件驱动处理,模型服务系统仅在需要时请求必要的计算资源。由于它们在用户发送请求后立即获得预测,而不是依赖于基于预定计划的预先计算的预测结果,因此应用可以提供更准确和实时的预测。
从开发者的角度来看,事件驱动处理模式的一个好处是它非常直观。例如,它极大地简化了将代码部署到运行中的服务的过程,因为除了源代码本身之外,没有需要创建或推送到源代码之外的最终产物。事件驱动处理模式使得从我们的笔记本电脑或网络浏览器部署代码到云中运行变得简单。
在我们的场景中,我们只需要部署可能根据用户请求触发的训练好的机器学习模型,作为函数。一旦部署,这个模型服务函数就会自动管理和扩展,无需开发者手动分配资源。换句话说,随着服务上承载的流量增加,模型服务函数的实例也会增加,以使用共享资源池来处理流量的增加。如果模型服务函数由于机器故障而失败,它将在共享资源池中的其他机器上自动重启。
考虑到事件驱动处理模式的特点,用于处理模型服务请求的每个函数都需要是无状态的并且独立于其他模型服务请求。每个函数实例不能有本地内存,这意味着所有状态都需要存储在存储服务中。例如,如果我们的机器学习模型高度依赖于先前预测的结果(例如,时间序列模型),在这种情况下,事件驱动处理模式可能不适合。
4.4.4 练习
-
假设我们在模型服务系统的整个生命周期内为酒店价格预测分配相同数量的计算资源。随着时间的推移,资源利用率率会是什么样的?
-
复制的服务或分片的服务是长运行系统吗?
-
事件驱动处理是有状态的还是无状态的?
4.5 练习答案
第 4.2 节
-
无状态的
-
模型服务器副本不知道应该处理哪些用户请求,当多个模型服务器副本尝试处理相同的请求时,可能会出现潜在的冲突或重复工作。
-
是的,只有当单个服务器每天的停机时间不超过 1.4 分钟时
第 4.3 节
-
是的,它有帮助,但会降低整体资源利用率。
-
有状态的
第 4.4 节
-
它随时间变化,取决于流量。
-
是的。服务器需要保持运行以接受用户请求,并且计算资源需要始终分配和占用。
-
无状态的
摘要
-
模型服务是将先前训练好的机器学习模型加载到内存中,生成预测或对新输入数据进行推理的过程。
-
复制的服务有助于处理不断增长的模型服务请求数量,并在复制的服务帮助下实现水平扩展。
-
分片服务模式允许系统处理大型请求,并有效地将处理大型模型服务请求的工作负载分配给多个模型服务器分片。
-
使用事件驱动处理模式,我们可以确保我们的系统只使用处理每个请求所必需的资源,无需担心资源利用率和空闲。
5 工作流程模式
本章涵盖
-
使用工作流程连接机器学习系统组件
-
使用扇入和扇出模式在机器学习工作流程中构建复杂但可维护的结构
-
使用同步和异步模式通过并发步骤加速机器学习工作负载
-
使用步骤记忆化模式提高性能
模型服务是在成功训练机器学习模型之后的关键步骤。它是整个机器学习工作流程产生的最终成果,模型服务的成果直接呈现给用户。之前,我们探讨了分布式模型服务系统中的一些挑战——例如,如何处理不断增长的模型服务请求数量和这些请求的增大——并调查了在行业中广泛采用的几个已建立的模式。我们学习了如何通过复制服务来实现水平扩展以解决这些挑战,以及如何使用分片服务模式帮助系统处理大量的模型服务请求。最后,我们学习了如何评估模型服务系统,并确定在现实世界场景中事件驱动设计是否会带来益处。
工作流程是机器学习系统中的一个基本组件,因为它连接了系统中的所有其他组件。机器学习工作流程可能非常简单,就像链式数据摄取、模型训练和模型服务。然而,处理需要额外步骤和性能优化的现实世界场景可能非常复杂,这些步骤和优化是整个工作流程的一部分。了解在设计决策中可能遇到哪些权衡,以满足不同的业务和性能要求,这是非常重要的。
在本章中,我们将探讨在实践构建机器学习工作流程时涉及的一些挑战。这些已建立的模式可以被重用来构建从简单到复杂的机器学习工作流程,这些工作流程既高效又可扩展。例如,我们将看到如何构建一个系统来执行复杂的机器学习工作流程,以训练多个机器学习模型。我们将使用扇入和扇出模式来选择在模型服务系统中提供良好实体标记结果的性能最优模型。我们还将结合同步和异步模式,使机器学习工作流程更加高效,并避免由于长时间运行的模型训练步骤而导致的延迟,这些步骤会阻塞其他连续步骤。
5.1 什么是工作流程?
工作流程是连接端到端机器学习系统中多个组件或步骤的过程。工作流程由现实世界中机器学习应用中常见的组件的任意组合组成,例如数据摄取、分布式模型训练和模型服务,如前几章所述。
图 5.1 展示了简单的机器学习工作流。该工作流连接了端到端机器学习系统中的多个组件或步骤,包括以下步骤:
-
数据摄入——消费 YouTube-8M 视频数据集
-
模型训练——训练实体标记模型
-
模型服务——对未见过的视频中的实体进行标记
注意:机器学习工作流通常被称为机器学习管道。我在这里交替使用这两个术语。尽管我使用不同的术语来指代不同的技术,但在这本书中这两个术语之间没有区别。
由于机器学习工作流可能由任何组合的组件组成,因此我们在不同情况下经常看到不同形式的机器学习工作流。与图 5.1 中展示的直接工作流不同,图 5.2 说明了更复杂的流程,其中在单个数据摄入步骤之后启动了两个独立的模型训练步骤,然后使用两个独立的模型服务步骤来服务通过不同模型训练步骤训练的不同模型。

图 5.1 展示了简单的机器学习流程图,包括数据摄入、模型训练和模型服务。箭头表示方向。例如,右侧的箭头表示步骤执行的顺序(例如,工作流在模型训练步骤完成后执行模型服务步骤)。

图 5.2 展示了更复杂的流程,其中在单个数据摄入步骤之后启动了两个独立的模型训练步骤,然后使用两个独立的模型服务步骤来服务通过不同模型训练步骤训练的不同模型。
图 5.1 和图 5.2 只是常见的一些例子。在实践中,机器学习工作流的复杂性各不相同,这增加了构建和维护可扩展机器学习系统的难度。
我们将在本章中讨论一些更复杂的机器学习工作流,但首先,我将介绍并区分以下两个概念之间的差异:顺序工作流和有向无环图(DAG)。
一个顺序工作流表示一系列依次执行直到系列中的最后一个步骤完成的步骤。执行的确切顺序可能不同,但步骤始终是顺序的。图 5.3 是一个包含三个顺序执行的步骤的示例顺序工作流。

图 5.3 展示了三个步骤按以下顺序执行的示例顺序工作流:A、B 和 C。
如果一个工作流仅由从一步到另一步的定向步骤组成,但永远不会形成一个闭环,那么它可以被看作是一个有向无环图(DAG)。
例如,图 5.3 中的工作流程是一个有效有向无环图(DAG),因为三个步骤是从步骤 A 到步骤 B,然后从步骤 B 到步骤 C 的有向的——循环没有闭合。然而,另一个如图 5.4 所示的工作流程不是一个有效有向无环图(DAG),因为有一个额外的步骤 D 从步骤 C 连接到步骤 A,形成一个闭合循环。

图 5.4 一个示例工作流程,其中步骤 D 从步骤 C 连接到步骤 A。这些连接形成一个闭合循环,因此整个工作流程不是一个有效有向无环图(DAG)。
如果步骤 D 不指向步骤 A,如图 5.5 所示,箭头被划掉,则此工作流程成为一个有效有向无环图(DAG)。循环不再闭合,因此它变成了一个简单的顺序工作流程,类似于图 5.3。

图 5.5 一个示例工作流程,其中最后一步 D 不指向步骤 A。由于闭合循环不再存在,此工作流程不是一个有效有向无环图(DAG)。相反,它是一个类似于图 5.3 的简单顺序工作流程。
在现实世界的机器学习应用中,满足不同用例(例如,模型的批量重新训练、超参数调整实验等)所需的工作流程可能会变得非常复杂。我们将探讨一些更复杂的工作流程,并抽象出可以重用于组合各种场景的结构模式。
5.2 输入和输出模式:组合复杂的机器学习工作流程
在第三章中,我们使用 YouTube-8M 数据集构建了一个机器学习模型,用于标记模型之前未见过的新闻视频的主要主题。YouTube-8M 数据集包含数百万个 YouTube 视频 ID,以及来自 3,800 多个视觉实体(如食品、汽车、音乐等)的高质量的机器生成注释。在第四章中,我们还讨论了有助于构建可扩展的模型服务系统的模式,用户可以上传新视频,然后系统加载之前训练好的机器学习模型来标记上传视频中的实体/主题。在实际应用中,我们通常希望将这些步骤链接起来,并以易于重用和分发的方式打包。
例如,如果原始 YouTube-8M 数据集已经更新,而我们想使用相同的模型架构从头开始训练一个新模型?在这种情况下,将每个组件容器化并将它们在机器学习工作流程中链接起来以供重用是非常容易的。当数据更新时,通过重新执行端到端工作流程来重用该工作流程。如图 5.6 所示,新视频定期添加到原始 YouTube-8M 数据集中,并且每次数据集更新时都会执行工作流程。下一个模型训练步骤使用最新的数据集训练实体标记模型。然后,最后一个模型服务步骤使用训练好的模型对未见过的视频中的实体进行标记。

图 5.6 新视频定期添加到原始 YouTube-8M 数据集中,并且每次数据集更新时都会执行工作流程。
现在,让我们看看一个更复杂的真实世界场景。假设我们知道任何机器学习模型架构的模型训练实现细节。我们希望构建一个机器学习系统来训练不同的模型。然后,我们希望使用前两个模型来生成预测,这样整个系统就不太可能错过视频中的任何实体,因为两个模型可能从不同的角度捕获信息。
5.2.1 问题
我们希望构建一个机器学习工作流程,在系统从数据源摄取数据后,能够训练不同的模型。然后,我们希望选择前两个模型,并利用这两个模型的知识来提供模型服务,为用户提供预测。
构建一个包含机器学习系统端到端正常流程的工作流程,仅包括数据摄取、模型训练和模型服务,其中每个组件在每个工作流程步骤中仅作为单独的步骤出现一次,这相当直接。然而,在我们的特定场景中,工作流程要复杂得多,因为我们需要包括多个模型训练步骤以及多个模型服务步骤。我们如何形式化和一般化这个复杂工作流程的结构,以便它可以轻松打包、重用和分发?
5.2.2 解决方案
让我们从最基础的机器学习工作流程开始,该工作流程仅包括数据摄取、模型训练和模型服务,其中这些组件在每个工作流程步骤中仅作为单独的步骤出现一次。我们将基于此工作流程构建我们的系统,作为我们的基线,如图 5.7 所示。

图 5.7 包含仅数据摄取、模型训练和模型服务的基线工作流程,其中这些组件在每个工作流程步骤中仅出现一次作为单独的步骤
我们的目标是表示构建和选择用于模型服务的表现最佳的两个模型的机器学习工作流程。让我们花点时间来理解为什么这种方法在实践中可能会被使用。例如,图 5.8 显示了两个模型:第一个模型了解四个实体,第二个模型了解三个实体。因此,每个模型都可以从视频中标记它所知道的实体。我们可以同时使用这两个模型来标记实体,然后汇总它们的结果。汇总的结果显然更知识渊博,能够覆盖更多实体。换句话说,两个模型可以更有效,产生更全面的实体标记结果。

图 5.8 模型图,第一个模型了解四个实体,第二个模型了解三个实体。因此,每个模型都可以从视频中标记它所知道的实体。我们可以同时使用这两个模型来标记实体,然后汇总它们的结果。汇总结果覆盖了比每个单独模型更多的实体。
既然我们理解了构建这个复杂工作流程背后的动机,那么让我们看一下整个端到端工作流程过程的概述。我们希望构建一个机器学习工作流程,该工作流程按顺序执行以下功能:
-
从相同的数据源摄取数据
-
训练多个不同的模型,这些模型可以是同一模型架构的不同超参数集合,或者是各种模型架构。
-
选择两个表现最好的模型用于每个训练模型的模型服务
-
将两个模型服务系统的模型结果汇总以呈现给用户
让我们先在数据摄取后的基线工作流程中添加一些占位符以用于多个模型训练步骤。一旦多个模型训练步骤完成,我们就可以添加多个模型服务步骤。增强型基线工作流程的图示如图 5.9 所示。

图 5.9 增强型基线工作流程图,在数据摄取之后发生多个模型训练步骤,随后是多个模型服务步骤
与我们在基线中之前处理的不同之处在于存在多个模型训练和模型服务组件。步骤之间没有直接的、一对一的关系。例如,每个模型训练步骤可能连接到单个模型服务步骤,也可能不连接到任何步骤。图 5.10 显示,从前两个模型训练步骤训练的模型优于从第三个模型训练步骤训练的模型。因此,只有前两个模型训练步骤连接到模型服务步骤。

图 5.10 从前两个模型训练步骤训练的模型优于从第三个模型训练步骤训练的模型。因此,只有前两个模型训练步骤连接到模型服务步骤。
我们可以这样构建这个工作流程。在成功的数据摄取后,多个模型训练步骤连接到数据摄取步骤,以便它们可以使用从原始数据源摄取并清洗的共享数据。接下来,一个步骤连接到模型训练步骤以选择表现最好的两个模型。它产生两个模型服务步骤,使用所选模型处理来自用户的模型服务请求。在这个机器学习工作流程的末尾,一个最终步骤连接到两个模型服务步骤,以汇总将呈现给用户的模型推理结果。
完整工作流程的图示显示在图 5.11 中。该工作流程通过三个模型训练步骤训练不同的模型,在实体标注时产生不同的准确率。一个模型选择步骤从前两个模型训练步骤中选取至少 90%准确率的两个顶级模型,这些模型将在接下来的两个单独的模型服务步骤中使用。然后,通过结果聚合步骤将两个模型服务步骤的结果汇总,以供用户查看。

图 5.11 一个机器学习工作流程,该工作流程训练不同的模型,在实体标注时产生不同的准确率,然后选择至少 90%准确率的两个顶级模型用于模型服务。然后,通过结果聚合步骤将两个模型服务步骤的结果汇总,以供用户查看。
我们可以从这个复杂的工作流程中抽象出两种模式。首先观察到的是扇出模式。扇出描述了启动多个独立的步骤来处理工作流程输入的过程。在我们的工作流程中,如图 5.12 所示,当多个独立的模型训练步骤连接到数据摄取步骤时,就会出现扇出模式。

图 5.12 当多个独立的模型训练步骤连接到数据摄取步骤时出现的扇出模式图示
在我们的工作流程中,也存在扇入模式,其中我们有一个单一的聚合步骤,将两个模型服务步骤的结果合并,如图 5.13 所示。扇入描述了将多个步骤的结果合并到一个步骤的过程。

图 5.13 扇入模式的图示,其中我们有一个单一的聚合步骤,将两个模型服务步骤的结果合并
将这些模式形式化将有助于我们通过使用不同模式来构建和组织更复杂的工作流程,这些模式基于现实世界的需求。
我们已经成功构建了一个复杂的工作流程,该工作流程训练不同的模型,然后使用顶级的前两个模型生成预测,从而使整个系统在视频中不太可能遗漏任何实体。当构建满足现实世界需求复杂工作流程时,这些模式非常强大。我们可以构建各种工作流程,从单一的数据处理步骤到多个模型训练步骤,以使用相同的数据集训练不同的模型。如果不同模型的预测在现实世界应用中有用,我们还可以从每个模型训练步骤启动多个模型服务步骤。我们将在第 9.4.1 节中应用此模式。
5.2.3 讨论
通过在系统中使用扇入和扇出模式,系统现在能够执行复杂的流程,这些流程训练多个机器学习模型,并选择性能最佳的模型以在模型服务系统中提供良好的实体标注结果。
这些模式是很好的抽象,可以融入非常复杂的流程中,以满足现实世界中日益增长的复杂分布式机器学习流程的需求。但哪些工作流程适合扇入和扇出模式?一般来说,如果以下两个条件都适用,我们可以考虑融入这些模式:
-
我们正在扇入或扇出的多个步骤是相互独立的。
-
这些步骤按顺序运行需要很长时间。
多个步骤需要是无序的,因为我们不知道这些步骤的并发副本将按什么顺序运行,也不知道它们将按什么顺序返回。例如,如果工作流程还包含一个训练其他模型集合(也称为集成学习;mng.bz/N2vn)的步骤,以提供更好的聚合模型,这个集成模型依赖于其他模型训练步骤的完成。因此,我们不能使用扇入模式,因为集成模型训练步骤需要在其他模型训练完成后才能开始运行,这会需要额外的等待并延迟整个工作流程。
集成模型
集成模型使用多个机器学习模型来获得比任何单个组成模型单独获得的更好的预测性能。它通常由多个替代模型组成,可以从不同的角度学习数据集中的关系。
当组成模型之间的多样性显著时,集成模型往往会产生更好的结果。因此,许多集成方法试图增加它们组合的模型的多样性。
扇入和扇出模式可以创建非常复杂的流程,满足机器学习系统的大部分需求。然而,为了在这些复杂的流程上实现良好的性能,我们需要确定哪些流程部分应该首先运行,哪些流程部分可以并行执行。由于优化,数据科学团队将花费更少的时间等待流程完成,从而降低基础设施成本。在下一节中,我将介绍一些模式,帮助我们从计算角度组织工作流程中的步骤。
5.2.4 练习
-
如果步骤之间不是相互独立的,我们能否使用扇入或扇出模式?
-
使用扇入模式构建集成模型时,主要问题是什么?
5.3 同步和异步模式:通过并发加速工作流程
系统中的每个模型训练步骤都需要很长时间才能完成;然而,它们的持续时间可能因不同的模型架构或模型参数而异。想象一个极端情况,其中一个模型训练步骤需要两周时间才能完成,因为它正在训练一个需要大量计算资源的复杂机器学习模型。我们之前构建的机器学习工作流程中,许多步骤,如模型选择和模型服务,使用扇入和扇出模式,将不得不额外等待一周,直到这个长时间运行的模型训练步骤完成。图 5.14 展示了三个模型训练步骤之间持续时间差异的示意图。

图 5.14 一个说明三个模型训练步骤持续时间差异的工作流程
在这种情况下,由于模型选择步骤及其后续步骤需要所有模型训练步骤完成,因此需要两周时间完成的模型训练步骤将使工作流程整体慢一周。我们宁愿用那额外的一周重新执行所有只需一周完成的模型训练步骤,而不是浪费时间等待一个步骤!
5.3.1 问题
我们希望构建一个机器学习工作流程,该工作流程训练不同的模型,然后选择前两个模型用于模型服务,该服务基于两个模型的知识生成预测。由于现有机器学习工作流程中每个模型训练步骤的完成时间不同,因此后续步骤,如模型选择步骤和模型服务,的开始依赖于前一步骤的完成。
然而,当至少有一个模型训练步骤的完成时间比其他步骤长得多时,会出现问题,因为后续的模型选择步骤只能在长时间运行的模型训练步骤完成后才能开始。结果,整个工作流程因此特别长时间运行的步骤而延迟。有没有办法加速这个工作流程,使其不会受到单个步骤持续时间的影响?
5.3.2 解决方案
我们希望构建与之前相同的工作流程,该工作流程在系统从数据源摄取数据后,训练不同的模型,选择前两个模型,然后使用这两个模型提供模型服务,以生成使用两个模型知识的预测。
然而,这次我们注意到一个性能瓶颈,因为每个后续步骤的开始,例如模型选择和模型服务,都依赖于其前一步骤的完成。在我们的案例中,我们有一个必须完成才能进行下一步的长时间运行的模型训练步骤。
如果我们可以完全排除长时间运行的模型训练步骤,会怎样?一旦我们这样做,其余的模型训练步骤将具有一致的完成时间。因此,工作流程中剩余的步骤可以在等待某个仍在运行的特定步骤之前执行。更新后的工作流程图如图 5.15 所示。

图 5.15 移除长时间运行的模型训练步骤后的新工作流程
这种简单的方法可能解决了我们长时间等待长运行步骤的问题。然而,我们的原始目标是使用这种复杂的流程来实验不同的机器学习模型架构和这些模型的超参数集,以选择最佳性能的模型用于模型服务。如果我们简单地排除长运行模型训练步骤,我们实际上是在放弃实验可能更好地捕捉视频实体的高级模型的机会。
是否有更好的方法来加速工作流程,使其不会受到单个步骤持续时间的影响?让我们专注于那些只需一周就能完成的模型训练步骤。当这些短时间运行的模型训练步骤完成后,我们能做什么?
当模型训练步骤完成时,我们已经成功获得了一个训练好的机器学习模型。实际上,我们可以在模型服务系统中使用这个训练好的模型,而无需等待模型训练步骤的其余部分完成。因此,当我们在工作流程中的某个步骤中训练了一个模型后,用户就可以看到他们模型服务请求中包含的视频的标记实体结果。这个工作流程的图如图 5.16 所示。

图 5.16 在短时间运行的模型训练步骤中训练的模型直接应用于我们的模型服务系统,无需等待剩余的模型训练步骤完成的工作流程
在第二个模型训练步骤完成后,我们可以直接将两个训练好的模型传递给模型服务。向用户展示的是综合推理结果,而不是仅从最初获得的模型中得到的推理结果,如图 5.17 所示。

图 5.17 在第二个模型训练步骤完成后,我们直接将两个训练好的模型传递给模型服务。向用户展示的是综合推理结果,而不仅仅是最初获得的模型的推理结果。
注意,虽然我们可以继续使用训练好的模型进行模型选择和模型服务,但长时间运行的模型训练步骤仍在进行中。换句话说,这些步骤是异步执行的——它们之间不依赖于彼此的完成。工作流程在上一步骤完成之前就开始执行下一步骤。
顺序步骤一次执行一个,并且只有当上一个步骤完成时,下一个步骤才会解除阻塞。换句话说,您必须等待一个步骤完成才能进行下一个步骤。例如,数据摄取步骤必须完成,我们才能开始任何模型训练步骤。
与异步步骤相反,一旦满足依赖关系,同步步骤可以同时开始运行。例如,模型训练步骤可以并发运行,一旦之前的数据摄取步骤完成即可。不同的模型训练步骤不需要等待另一个开始。同步模式通常在您有多个可以并发运行且几乎同时完成的相似工作负载时非常有用。
通过结合这些模式,整个工作流程将不再被长时间运行的模型训练步骤阻塞。相反,它可以使用模型服务系统中已经训练好的模型,这些模型可以开始处理用户的模型服务请求。
同步和异步模式在其他分布式系统中也非常有用,可以优化系统性能并最大化现有计算资源的使用——尤其是在重负载的计算资源有限时。我们将在第 9.4.1 节中应用此模式。
5.3.3 讨论
通过混合同步和异步模式,我们可以创建更高效的机器学习工作流程,并避免由于阻止其他步骤执行而导致的任何延迟,例如长时间运行的模型训练步骤。然而,来自时间较短的模型训练步骤训练的模型可能不太准确。也就是说,具有更简单架构的模型可能不会在视频中识别出像长时间运行的模型训练步骤(图 5.18)中更复杂的模型那样多的实体。

图 5.18 由两个完成的时间较短的模型训练步骤训练的模型,这些步骤使用了非常简单的模型作为基线。它们只能识别少量实体,而来自耗时最长的步骤训练的模型可以识别更多的实体。
因此,我们应该记住,我们早期得到的模型可能不是最好的,可能只能标记少量实体,这可能不会满足我们的用户需求。
当我们将这个端到端工作流程部署到现实世界应用中时,我们需要考虑用户看到推理结果更快还是看到更好的结果更重要。如果目标是允许用户在新模型可用时立即看到推理结果,他们可能看不到他们预期的结果。然而,如果用户可以容忍一定程度的延迟,等待更多的模型训练步骤完成会更好。然后,我们可以选择性地挑选我们训练的模型,并选择那些提供非常好的实体标记结果的性能最好的模型。是否可以接受延迟取决于现实世界应用的要求。
通过使用同步和异步模式,我们可以从结构和计算的角度组织机器学习工作流程中的步骤。因此,数据科学团队可以花费更少的时间等待工作流程完成,以最大化性能,从而降低基础设施成本和闲置的计算资源。在下一节中,我们将介绍在现实世界系统中经常使用的一种模式,它可以节省更多的计算资源,并使工作流程运行得更快。
5.3.4 练习
-
模型训练步骤的每个步骤是由什么引起的?
-
如果步骤异步运行,它们会相互阻塞吗?
-
在决定是否尽可能早地使用任何可用的训练模型时,我们需要考虑什么?
5.4 步骤记忆化模式:通过记忆化步骤跳过冗余工作负载
在工作流程中采用扇入和扇出模式后,系统可以执行复杂的流程,这些流程训练多个机器学习模型,并选择性能最好的模型在模型服务系统中提供良好的实体标记结果。我们在这章中看到的工作流程只包含一个数据摄入步骤。换句话说,在工作流程中,数据摄入步骤总是首先执行,然后剩余的步骤,如模型训练和模型服务,才能开始处理。
不幸的是,在现实世界的机器学习应用中,数据集并不总是保持不变。现在,想象一下新的 YouTube 视频每周都在变得可用,并被添加到 YouTube-8M 数据集中。按照我们现有的工作流程架构,如果我们想重新训练模型以考虑定期到达的额外视频,我们需要定期从头开始运行整个工作流程——从数据摄入步骤到模型服务步骤,如图 5.19 所示。

图 5.19 每次数据集更新时重新执行的整体工作流程图
假设数据集没有变化,但我们要尝试新的模型架构或新的超参数集,这在机器学习从业者中非常常见(见图 5.20)。例如,我们可能将模型架构从简单的线性模型更改为更复杂的模型,如基于树的模型或卷积神经网络。我们也可以坚持使用我们使用的特定模型架构,并仅更改模型超参数集,例如神经网络模型中每层的层数和隐藏单元的数量,或基于树的模型中每棵树的深度。对于这些情况,我们仍然需要运行端到端的流程,包括从原始数据源从头开始重新摄入数据的步骤。再次进行数据摄入是非常耗时的。

图 5.20 每次我们尝试新的模型类型或超参数时,即使数据集没有变化,整个流程都需要重新执行。
5.4.1 问题
机器学习流程通常从数据摄入步骤开始。如果数据集正在定期更新,我们可能希望重新运行整个流程以训练一个考虑新数据的全新机器学习模型。为此,我们需要每次都执行数据摄入步骤。或者,如果数据集没有更新,但我们要尝试新的模型,我们仍然需要执行整个流程,包括数据摄入步骤。然而,数据摄入步骤可能需要很长时间才能完成,这取决于数据集的大小。有没有办法使这个流程更高效?
5.4.2 解决方案
由于数据摄入步骤通常非常耗时,我们可能不想每次流程运行时都重新执行它来重新训练或更新我们的实体标记模型。让我们首先考虑这个问题的根本原因。YouTube 视频的数据集正在定期更新,并且新数据定期持久化到数据源上(例如,每月一次)。
我们有两个用例需要重新执行整个机器学习流程:
-
数据集更新后,重新运行流程以训练一个使用更新数据集的新模型。
-
我们想使用已经摄入的数据集尝试新的模型架构,这些数据可能尚未更新。
基本问题是耗时的数据摄入步骤。在当前的流程架构中,无论数据集是否已更新,数据摄入步骤都需要执行。
理想情况下,如果新数据尚未更新,我们不想重新摄入已经收集的数据。换句话说,我们希望在知道数据集已更新时才执行数据摄入步骤,如图 5.21 所示。

图 5.21 当数据集未更新时跳过数据摄入步骤的示意图
现在的挑战在于确定数据集是否已被更新。一旦我们有了识别这一点的办法,我们就可以有条件地重建机器学习工作流程,并控制是否想要重新执行数据摄入步骤(图 5.21)。
识别数据集是否已被更新的方法之一是通过使用缓存。由于我们的数据集正在按照固定的时间表定期更新(例如,每月一次),我们可以创建一个基于时间的缓存,存储摄入和清洗后的数据集的位置以及其最后更新时间戳。工作流程中的数据摄入步骤将根据最后更新时间戳是否在特定窗口内动态构建和执行。例如,如果时间窗口设置为两周,那么如果数据在过去两周内更新,我们就认为摄入的数据是新鲜的。数据摄入步骤将被跳过,接下来的模型训练步骤将直接使用缓存中存储的已摄入数据集。
图 5.22 说明了工作流程已被触发,并且通过访问缓存检查数据是否在过去两周内更新。如果数据是新鲜的,我们将跳过执行不必要的摄入步骤,并直接执行模型训练步骤。

图 5.22 工作流程已被触发,通过访问缓存检查数据是否在过去两周内更新。如果数据是新鲜的,我们将跳过执行不必要的摄入步骤,并直接执行模型训练步骤。
时间窗口可以用来控制缓存可以有多旧,在我们认为数据集足够新鲜可以直接用于模型训练而不是重新从头开始摄入数据之前。
或者,我们可以在缓存中存储一些关于数据源的重要元数据,例如当前可用的原始数据源中的记录数。这种类型的缓存被称为基于内容的缓存,因为它存储从特定步骤提取的信息,例如输入和输出信息。有了这种类型的缓存,我们可以识别数据源是否有重大变化(例如,数据源中的原始记录数翻倍)。如果有重大变化,通常是一个重新执行数据摄入步骤的信号,因为当前的数据集非常旧且过时。图 5.23 展示了说明这种方法的流程图。

图 5.23 工作流程已被触发,我们检查从数据集中收集的元数据,例如数据集中的记录数是否发生了显著变化。如果没有显著变化,我们就跳过执行不必要的数据处理步骤,直接执行模型训练步骤。
这种使用缓存来确定是否执行步骤或跳过的模式被称为步骤记忆化。借助步骤记忆化,工作流程可以识别出那些可以跳过而不需要重新执行的重负载步骤,从而大大加速端到端工作流程的执行。我们将在第 9.4.2 节中应用这个模式。
5.4.3 讨论
在现实世界的机器学习应用中,除了数据处理之外,许多工作负载都是计算密集型和耗时的。例如,模型训练步骤需要大量的计算资源来实现高性能的模型训练,有时可能需要几周时间才能完成。如果我们只是在实验其他不需要更新训练模型的组件,那么避免重新执行昂贵的模型训练步骤可能是有意义的。在决定是否可以跳过重负载和冗余步骤时,步骤记忆模式非常有用。
如果我们正在创建基于内容的数据缓存,关于要提取和存储在缓存中的信息类型的决策可能并不简单。例如,如果我们试图缓存模型训练步骤的结果,我们可能希望考虑使用包含诸如机器学习模型类型和模型超参数集等信息的训练模型工件。当工作流程再次执行时,它将根据我们是否尝试相同的模型来决定是否重新执行模型训练步骤。或者,我们可能存储诸如性能统计信息(例如,准确率、均方误差等)来识别是否超过了阈值,并且值得训练一个性能更好的模型。
此外,在实践应用步骤记忆模式时,请注意,它需要一定程度的维护工作来管理创建的缓存的生命周期。例如,如果每天有 1,000 个机器学习工作流程运行,每个工作流程平均有 100 个步骤被记忆,那么每天将创建 100,000 个缓存。根据它们存储的信息类型,这些缓存需要一定量的空间,这些空间可能会迅速积累。
为了在规模上应用此模式,必须有一个垃圾收集机制来自动删除不必要的缓存,以防止缓存积累占用大量磁盘空间。例如,一个简单的策略是记录缓存最后一次被工作流程中的步骤击中和使用的时间戳,然后定期扫描现有的缓存,清理那些未被使用或长时间未被击中的缓存。
5.4.4 练习
-
哪种类型的步骤最能从步骤记忆化中受益?
-
如果一个步骤的工作流程被触发再次运行,我们如何判断该步骤的执行是否可以跳过?
-
一旦我们使用该模式进行大规模应用,我们需要管理和维护什么?
5.5 练习答案
第 5.2 节
-
不,因为我们没有保证这些步骤的并发副本将按什么顺序运行
-
训练集成模型取决于完成子模型的模型训练步骤。我们不能使用扇入模式,因为集成模型训练步骤将需要等待其他模型训练完成才能开始运行,这将需要额外的等待并延迟整个工作流程。
第 5.3 节
-
由于现有机器学习工作流程中每个模型训练步骤完成时间的差异,每个后续步骤(如模型选择和模型服务)的开始都取决于前一个步骤的完成。
-
不,异步步骤不会互相阻塞。
-
从用户的角度来看,我们需要考虑是否希望尽可能早地使用任何可用的训练模型。我们应该考虑对用户来说,是更快地看到推理结果更重要,还是看到更好的结果更重要。如果目标是允许用户在新的模型可用时立即看到推理结果,那么这些结果可能不够好或不是用户所期望的。或者,如果用户可以接受一定的延迟,等待更多的模型训练步骤完成可能更可取。然后,您可以有选择性地选择训练模型,并挑选出性能最好的模型,这将提供非常好的实体标记结果。
第 5.4 节
-
耗时或需要大量计算资源的步骤
-
我们可以使用存储在缓存中的信息,例如缓存最初创建时或从步骤收集的元数据,来决定是否应该跳过执行特定的步骤。
-
我们需要设置一个垃圾回收机制来自动回收和删除创建的缓存。
摘要
-
工作流程是机器学习系统中的一个基本组件,因为它将机器学习系统中的所有其他组件连接起来。机器学习工作流程可以像链式数据摄取、模型训练和模型服务一样简单。
-
将扇入和扇出模式纳入复杂的工作流程中,可以使它们易于维护和组合。
-
同步和异步模式通过并发加速机器学习工作负载。
-
步骤记忆化模式通过跳过重复的工作负载来提高工作流程的性能。
6 操作模式
本章涵盖了
-
识别机器学习系统中的改进领域,例如作业调度和元数据
-
使用如公平共享调度、优先级调度和组调度等技术来防止资源饥饿和避免死锁
-
通过元数据模式更有效地处理故障,以减少对用户的任何负面影响
在第五章中,我们关注了机器学习工作流程及其在实际构建中的挑战。工作流程是机器学习系统中的一个基本组成部分,因为它连接了系统中的所有组件。一个机器学习工作流程可以简单到只是将数据摄取、模型训练和模型服务串联起来。在处理现实世界场景时,它也可以非常复杂,需要额外的步骤和性能优化才能成为整个工作流程的一部分。
了解在做出设计决策以满足特定业务和性能要求时可能遇到的权衡是至关重要的。我之前介绍了一些在工业界普遍采用的成熟模式。每个模式都可以被重用来构建从简单到复杂的、高效且可扩展的机器学习工作流程。例如,我们学习了如何使用扇入和扇出模式来构建一个执行复杂机器学习工作流程的系统(第 5.2 节)。这个系统可以训练多个机器学习模型,并选择性能最好的模型以提供良好的实体标记结果。我们还使用了同步和异步模式来提高机器学习工作流程的效率,并避免由于长时间运行的模型训练步骤而导致的延迟,这些步骤会阻塞其他步骤(第 5.3 节)。
由于现实世界的分布式机器学习工作流程可能非常复杂,正如第五章所见,维护和管理系统各个组件(如系统效率、可观察性、监控、部署等)需要大量的操作工作。这些操作工作通常需要 DevOps 团队和数据科学团队之间大量的沟通和协作。例如,DevOps 团队可能没有足够的数据科学团队使用的机器学习算法的领域知识来调试遇到的问题或优化底层基础设施以加速机器学习工作流程。对于数据科学团队来说,计算工作负载的类型因团队结构和团队成员的协作方式而异。因此,DevOps 团队没有处理来自数据科学团队的不同工作负载请求的通用方法。
幸运的是,操作努力和模式可以极大地加速端到端工作流程。它们还可以在系统成为生产就绪之前,当工程团队与数据科学家或机器学习实践者团队合作时,减少维护和沟通的努力。
在本章中,我们将探讨在实际操作机器学习系统时遇到的挑战,并介绍一些常用的模式。例如,我们将使用调度技术来防止资源饥饿和避免死锁,当许多团队成员在有限的计算资源下在同一集群中协作时。我们还将讨论元数据模式的益处,它可以提供对机器学习工作流程中各个步骤的见解,并帮助我们更恰当地处理故障,以减少对用户产生的任何负面影响。
6.1 机器学习系统中的操作是什么?
在本章中,我将重点关注在机器学习工作流程中常见于多个组件或步骤的操作技术和模式,而不是针对每个单独组件的模式。例如,图 6.1 所示的工作流程包括在数据摄入之后和多个模型训练步骤之后的多个模型服务步骤中发生的三个失败步骤。不幸的是,每个步骤都像一个黑盒,我们目前对它们的许多细节还一无所知。在这个阶段,我们只知道它们是否失败以及这些失败是否影响了后续步骤。因此,它们真的很难调试。

图 6.1 一个示例工作流程,其中在数据摄入之后发生多个模型训练步骤,在多个模型训练步骤之后发生多个模型服务步骤。注意三个失败的步骤。
我在本章中介绍的操作模式可以提高整个工作流程的可见性,帮助我们理解故障的根本原因,并给我们一些如何正确处理故障的想法。此外,提高的可观察性可能有助于我们开发对类似工作流程未来执行有益的系统效率改进。
那么,MLOps 是什么呢?
我们现在经常听到MLOps这个词,这是一个从机器学习和操作中衍生出来的术语。它通常意味着一组用于管理生产中机器学习生命周期的实践,包括来自机器学习和 DevOps 的实践,以高效和可靠地部署和管理生产中的机器学习模型。
MLOps 通常需要 DevOps 团队和数据科学团队之间的沟通和协作。它侧重于提高生产机器学习的质量,同时拥抱自动化,并保持业务需求。MLOps 的范围可能非常大,并且根据上下文而变化。
由于 MLOps 的范围可能非常大,这取决于上下文,我在写作时将仅关注一组成熟的模式。您可以期待随着这个领域的演变,本章的任何未来版本都将有所更新。
6.2 调度模式:在共享集群中有效分配资源
假设我们已经成功为用户设置了分布式基础设施,以便提交分布式模型训练作业,这些作业默认由调度器调度,在多个 CPU 上运行。调度器负责分配计算资源以执行系统请求的任务。它旨在保持计算资源忙碌,并允许多个用户更容易地共享资源进行协作。多个用户正在尝试使用集群中的共享计算资源构建模型,以适应不同的场景。例如,一位用户正在开发一个欺诈检测模型,试图识别诸如国际洗钱等欺诈金融行为。另一位用户正在开发一个条件监测模型,可以生成一个健康分数来表示工业资产(如火车、飞机、风力涡轮机等组件)的当前状态。
我们的基础设施最初只提供了一个简单的调度器,它按照先到先得的原则调度作业,如图 6.2 所示。例如,第三项作业是在第二项作业被调度之后调度的,并且每个作业的计算资源都是在调度时分配的。

图 6.2 仅提供简单调度器的基础设施示意图,该调度器按照先到先得的原则调度作业
换句话说,调度作业较晚的用户必须等待所有先前提交的作业完成,然后他们的模型训练作业才能开始执行。不幸的是,在现实世界中,用户通常希望提交多个模型训练作业以实验不同的模型集或超参数集。这些多个模型阻塞了其他用户的模型训练作业的执行,因为先前提交的实验已经利用了所有可用的计算资源。
在这种情况下,用户必须争夺资源(例如,在夜间唤醒以提交模型训练作业,因为此时使用系统的用户较少)。因此,团队成员之间的协作可能不会愉快。一些作业包括训练非常大的机器学习模型,这些模型通常消耗大量的计算资源,从而增加了其他用户等待其作业执行的时间。
此外,如果我们只为分布式模型训练作业调度部分请求的工人,则模型训练无法执行,直到所有请求的工人都准备好;分布策略的本质是具有集体通信模式的分布式训练。如果必要的计算资源不足,作业将永远不会启动,并且已分配给现有工人的计算资源将被浪费。
6.2.1 问题
我们已经为用户建立了一个分布式基础设施,用户可以通过默认调度器提交分布式模型训练作业,该调度器负责分配计算资源以执行用户请求的各种任务。然而,默认调度器仅提供一个简单的调度器,它按照先到先得的原则安排作业。因此,当多个用户尝试使用此集群时,他们通常需要等待很长时间才能获得可用的计算资源——也就是说,直到之前提交的作业完成。此外,由于分布式训练策略的性质,如集体通信策略,分布式模型训练作业必须等到所有请求的工人准备就绪后才能开始执行。那么,有没有任何替代现有的默认调度器的方法,以便我们可以在共享集群中更有效地分配计算资源?
6.2.2 解决方案
在我们的场景中,当多个用户同时尝试使用系统提交分布式模型训练作业时,问题开始出现。由于作业是按照先到先得的原则执行的,因此后来提交的作业的等待时间很长,即使这些作业是由多个用户提交的。
识别不同的用户很容易,因此一个直观的解决方案是限制每个用户分配的总计算资源量。例如,假设有四个用户(A、B、C 和 D)。一旦用户 A 提交了一个使用 25%总可用 CPU 周期(techterms.com/definition/clockcycle)的作业,他们就不能提交另一个作业,直到那些分配的资源被释放并准备好分配给新的作业。其他用户可以独立于用户 A 使用多少资源提交作业。例如,如果用户 B 启动了两个使用相同资源量的进程,这些进程将分别分配到 12.5%的总 CPU 周期,从而给用户 B 分配 25%的总资源。其他每个用户仍然获得 25%的总周期。图 6.3 说明了这四个用户的资源分配情况。

图 6.3 四个用户(A、B、C 和 D)的资源分配
如果新用户 E 在系统上启动一个进程,调度器将重新分配可用的 CPU 周期,以便每个用户获得整个的 20%(100% / 5 = 20%)。我们在图 6.3 中安排工作负载在集群中执行的方式被称为公平共享调度。这是一种计算机操作系统的调度算法,其中 CPU 使用量在系统用户或组之间平均分配,而不是在进程之间平均分配。
到目前为止,我们只讨论了在用户之间分配资源。当多个团队使用系统来训练他们的机器学习模型,并且每个团队有多名成员时,我们可以将用户划分为不同的组,然后对用户和组都应用公平共享调度算法。具体来说,我们首先将可用的 CPU 周期分配给组,然后在每个组内进一步分配给用户。例如,如果三个组分别包含三个、两个和四个用户,那么每个组将能够使用 33.3%(100% / 3)的总可用 CPU 周期。然后我们可以按照以下方式计算每个组中每个用户的可用 CPU 周期:
-
组 1—33.3% / 3 用户 = 每用户 11.1%
-
组 2—33.3% / 2 用户 = 每用户 16.7%
-
组 3—33.3% / 4 用户 = 每用户 8.3%
图 6.4 总结了我们对三个组中每个个体用户计算的资源分配。

图 6.4 三组中每个用户的资源分配总结
公平共享调度可以帮助我们解决多个用户同时运行分布式训练作业的问题。我们可以在每个抽象级别应用这种调度策略,例如进程、用户、组等。所有用户都有自己的可用资源池,不会相互干扰。
然而,在某些情况下,某些作业应该先执行。例如,集群管理员可能希望提交集群维护作业,如删除长时间占用资源的挂起作业。提前执行这些集群维护作业可以帮助释放更多计算资源,从而让其他人能够提交新的作业。
假设集群管理员是组 1 中的用户 1。另外两个非管理员用户也在组 1 中,就像之前的例子一样。用户 2 正在运行作业 1,该作业正在使用基于公平共享调度算法分配给他们的 11.1%的 CPU 周期。
尽管用户 2 有足够的计算能力来执行作业 1,但该作业依赖于用户 3 的作业 2 的成功。例如,用户 3 的作业 2 在数据库中生成一个表格,作业 1 需要该表格来执行分布式模型训练任务。图 6.5 总结了第一组中每个用户的资源分配和利用率。

图 6.5 第一组中每个用户的资源分配和利用率总结
不幸的是,由于数据库连接不稳定,作业 2 卡住了,并且一直在尝试重新连接以生成作业 1 所需的数据。为了解决这个问题,管理员需要提交作业 3 来终止并重新启动卡住的作业 2。
现在假设管理员用户 1 已经使用了 11.1%的总 CPU 周期。因此,由于维护作业 3 提交时间晚于所有之前的作业,它被添加到作业队列中,等待资源释放时执行,根据我们公平共享调度算法的先到先得性质。结果,我们遇到了一个死锁,没有任何作业可以继续进行,如图 6.6 所示。

图 6.6 在组 1 中的管理员用户(用户 1)试图调度一个作业来重启挂起的作业(作业 3),但遇到了一个死锁,没有任何作业可以继续进行。
为了解决这个问题,我们可以允许用户为每个作业分配优先级,这样具有更高优先级的作业将先执行,这与公平共享调度算法的先到先得性质相反。此外,如果可用计算资源不足,可以抢占或驱逐正在运行的作业,为具有更高优先级的作业腾出空间。这种基于优先级的作业调度方式称为优先级调度。
例如,有四个作业(A、B、C 和 D)同时提交。每个作业都由用户标记了优先级。作业 A 和 C 是高优先级,而作业 B 是低优先级,作业 D 是中等优先级。使用优先级调度,作业 A 和 C 将首先执行,因为它们的优先级最高,然后是具有中等优先级的作业 D,最后是低优先级的作业 B。图 6.7 说明了使用优先级调度时四个作业(A、B、C 和 D)的执行顺序。

图 6.7 使用优先级调度时,四个同时提交的作业(A、B、C 和 D)的执行顺序
让我们考虑另一个例子。假设有三个具有不同优先级的作业(B、C 和 D)同时提交,并按照它们的优先级执行,类似于前面的例子。如果提交了一个具有高优先级的作业(作业 A),在低优先级的作业 B 已经开始运行之后,作业 B 将被抢占,然后作业 A 开始。之前分配给作业 B 的计算资源将被释放并由作业 A 接管。图 6.8 总结了四个作业(A、B、C 和 D)的执行顺序,其中正在运行的低优先级作业 B 被具有更高优先级的新作业(作业 A)抢占。

图 6.8 正在运行的低优先级作业被具有更高优先级的新作业抢占的四个作业(A、B、C 和 D)的执行顺序
使用优先级调度,我们可以有效地消除我们之前遇到的问题,即作业只能按先到先得的顺序依次执行。现在可以优先执行具有高优先级的任务。
然而,对于分布式机器学习任务——特别是模型训练任务——我们希望在开始分布式训练之前确保所有工人都已准备好。否则,已经准备好的工人将等待剩余的工人,直到训练可以继续,这会浪费资源。
例如,在图 6.9 中,同一进程组中的三个工作进程正在进行 allreduce 操作。然而,由于底层分布式集群网络不稳定,有两个工人尚未准备好。因此,依赖于这些受影响通信的两个进程(进程 1 和 3)将无法及时接收到一些计算出的梯度值(v0 和 v2)(如图 6.9 中的问号所示),整个 allreduce 操作将一直停滞,直到所有信息都接收完毕。

图 6.9 展示了工作进程之间网络不稳定导致整个模型训练过程受阻的 allreduce 过程示例
成组调度通常用于运行分布式模型训练任务。它确保如果两个或多个工人相互通信,它们将同时准备好这样做。换句话说,成组调度仅在足够多的工人可用且准备好通信时调度工人。
如果它们没有被成组调度,一个工人可能需要在另一个工人睡眠时等待发送或接收消息,反之亦然。当工人在等待其他工人准备好通信时,我们正在浪费已经分配给准备好的工人的资源,整个分布式模型训练任务也因此停滞不前。
例如,对于基于集体通信的分布式模型训练任务,所有工人必须准备好通信计算出的梯度并更新每个工人上的模型,以完成 allreduce 操作。我假设机器学习框架目前还不支持弹性调度,我们将在下一节讨论。如图 6.10 所示,由于这些梯度尚未到达第二组中的任何工作进程,因此所有梯度都用问号表示。所有工作进程都尚未开始发送梯度,并且它们不会发送,直到网络稳定后所有进程都进入就绪状态。

图 6.10 展示了在成组调度下,工作进程将在网络稳定后所有进程都进入就绪状态时才开始发送梯度。
使用成组调度,我们可以确保在所有工人都准备好之前不启动任何工作进程,这样就没有一个进程会等待剩余的工作进程。因此,我们可以避免浪费计算资源。一旦网络变得稳定,所有梯度(v0、v1 和 v2)在成功的 allreduce 操作后都会到达每个工作进程,如图 6.11 所示。

图 6.11 在网络稳定后,所有梯度都会在各个工作者进程上成功完成 allreduce 操作。
注意:不同类型群组调度的细节及其算法超出了本书的范围,这里不会进行讨论。然而,我们将在本书的最后部分使用现有的开源框架将群组调度集成到分布式训练中。
通过结合不同的调度模式,我们能够解决当多个用户使用基础设施调度不同类型的作业时出现的各种问题。尽管我们研究了这些调度模式的一些特定用例,但这些模式可以在许多需要仔细管理计算资源的系统中找到,尤其是在资源稀缺的情况下。许多调度技术甚至应用于更底层的操作系统,以确保应用程序高效运行并合理共享资源。
6.2.3 讨论
我们已经看到公平共享调度如何帮助我们解决多个用户同时运行分布式训练作业时的问题。公平共享调度允许我们在每个抽象级别应用调度策略,例如进程、用户、组等。我们还讨论了优先级调度,它可以有效地消除当作业只能按先到先得的原则顺序执行时遇到的问题。优先级调度允许根据作业的优先级级别执行作业,抢占低优先级作业以腾出空间给高优先级作业。
使用优先级调度时,如果集群被大量用户使用,恶意用户可能会创建具有最高可能优先级的作业,导致其他作业被驱逐或根本无法调度。为了处理这种潜在问题,现实世界中的集群管理员通常会强制执行某些规则和限制,以防止用户创建大量高优先级的作业。
我们还讨论了群组调度,它确保如果有两个或更多工作者相互通信,他们都将同时准备好进行通信。群组调度对于基于集体通信的分布式模型训练作业特别有帮助,在这些作业中,所有工作者都需要准备好通信计算梯度,以避免浪费计算资源。
一些机器学习框架支持弹性调度(见第三章),允许分布式模型训练作业在任何数量的工作者可用时启动,而无需等待所有请求的工作者都准备好。在这种情况下,群组调度不适用,因为我们需要等待所有工作者都准备好。相反,我们可以通过弹性调度开始取得模型训练的重大进展。
由于工作进程的数量可能在模型训练期间发生变化,批大小(每个工作进程上迷你批次的尺寸之和)将影响模型训练的准确性。在这种情况下,需要对模型训练策略进行额外的修改。例如,我们可以支持一个定制的学习率调度器,该调度器将考虑纪元或批次,或根据工作进程的数量动态调整批大小。与这些算法改进相结合,我们可以更明智地分配和利用现有的计算资源,并提高用户体验。
在实践中,分布式模型训练作业极大地受益于像群调度这样的调度模式。因此,我们可以避免浪费计算资源。然而,我们可能忽视的一个问题是,任何由群调度安排的这些工作进程都可能失败,导致意外的后果。通常很难调试这类故障。在下一节中,我将介绍一种将使调试和处理故障更容易的模式。
6.2.4 练习
-
我们是否只能在用户级别应用公平共享调度?
-
群调度是否适合所有分布式模型训练作业?
6.3 元数据模式:适当处理故障以最小化对用户的不利影响
当构建最基础的仅包含数据摄入、模型训练和模型服务的机器学习工作流程时,其中每个组件在流程中仅作为单个步骤出现一次,一切似乎都很直接。每个步骤按顺序运行以达到完成。如果这些步骤中的任何一个失败,我们就从它留下的地方继续。例如,想象一下模型训练步骤未能处理摄入的数据(例如,失去了存储摄入数据的数据库的连接)。我们可以重试失败的步骤,并如图 6.12 所示,轻松地继续模型训练而无需重新运行整个数据摄入过程。

图 6.12 基线工作流程,其中模型训练步骤未能处理摄入的数据。我们重试失败的步骤,并从失败的步骤继续模型训练,而无需重新运行整个数据摄入过程。
然而,当工作流程变得更加复杂时,任何故障的处理都不再是微不足道的。例如,考虑第五章中的工作流程。该工作流程通过三个模型训练步骤来训练模型,这些步骤在标记实体时达到不同的准确性。然后,一个模型选择步骤从前两个模型训练步骤中至少训练出 90%准确性的前两个模型中选择,这些模型将在接下来的两个单独的模型服务步骤中使用。然后,通过结果聚合步骤将这两个模型服务步骤的结果汇总,以呈现给用户。
现在让我们考虑第二种情况,即第二和第三模型训练步骤在执行过程中都失败了(例如,分配给模型训练的一些工作者被抢占)。如果这两个模型训练步骤成功完成,它们将提供最准确和最不准确的模型,如图 6.13 所示。

图 6.13 展示了一个机器学习工作流程,在标记实体时训练具有不同准确率的模型。模型选择步骤识别出至少 90%准确率的两个顶级模型用于模型服务。这两个步骤中的准确率已被划掉,因为这些步骤在没有达到预期准确率的情况下失败了。然后,从两个模型服务步骤的结果中汇总以呈现给用户。
在这一点上,有人可能会认为我们应该重新运行这两个步骤以继续进行模型选择和模型服务步骤。然而,在实践中,由于我们已经浪费了一些时间训练部分模型,我们可能不想从头开始。我们的用户需要更长的时间才能看到我们最佳模型的汇总结果。是否有更好的方法来处理这类失败?
6.3.1 问题
对于复杂的机器学习工作流程,例如我们在第五章中讨论的,我们想要训练多个模型,然后选择表现最佳的模型进行模型服务,由于现实需求,处理某些步骤失败(例如,由于抢占式工作者)的策略决策并不总是简单的。例如,当三个模型训练步骤中有两个因抢占式工作者而失败时,我们不希望从头开始训练这些模型,这会大大增加完成工作流程所需的时间。我们如何适当地处理这些失败,以将对用户的影响降到最低?
6.3.2 解决方案
无论何时我们在机器学习工作流程中遇到失败,我们首先应该了解根本原因(例如,网络连接丢失、计算资源不足等)。了解根本原因很重要,因为我们需要了解失败的性质,以预测重试失败的步骤是否会有帮助。如果失败是由于一些可能非常可能导致重试时重复失败的长期短缺,我们可以更好地利用计算资源来运行其他任务。图 6.14 说明了重试永久性失败和暂时性失败的有效性差异。当我们遇到永久性失败时重试模型训练步骤,重试是无效的,并导致重复失败。

图 6.14 重试永久性失败和暂时性失败的有效性差异
例如,在我们的案例中,我们首先应该检查模型训练步骤的依赖项是否满足,例如,之前步骤中摄入的数据是否仍然可用。如果数据已持久化到本地磁盘或数据库中,我们可以继续进行模型训练。然而,如果数据位于内存中并且在模型训练步骤失败时丢失,我们无法在不重新摄入数据的情况下开始模型训练。图 6.15 显示了在模型训练过程中出现永久性故障时重新启动数据摄入步骤的过程。

图 6.15 在模型训练过程中发生永久性故障时重新启动数据摄入步骤的过程
类似地,如果模型训练步骤由于预占用的训练工作者或内存不足问题而失败,我们需要确保我们仍然有足够的计算资源分配来重新运行模型训练步骤。
然而,除非我们故意在机器学习工作流程中每个步骤的运行时将其记录为元数据,否则我们不知道要分析哪些信息来确定根本原因。例如,对于每个模型训练步骤,我们可以在步骤失败之前记录有关摄入数据的可用性和不同计算资源(如内存和 CPU 使用)是否超过限制的元数据。
图 6.16 是一个模型训练步骤失败的工作流程。在此步骤的运行时,每 5 分钟收集一次内存使用情况(以兆字节为单位)和训练数据可用性(是/否)的元数据。我们可以注意到,在 30 分钟后,内存使用量从 23 MB 突然增加到 200 MB。在这种情况下,我们可以尝试增加请求的内存来重试此步骤,然后它将成功产生一个用于下一个模型服务步骤的训练模型。

图 6.16 模型训练步骤失败的一个示例工作流程,其中收集的元数据显示了运行时出现的意外内存峰值
在实践中,对于如图 6.13 所示的复杂工作流程,即使我们知道模型训练步骤的所有依赖项都已满足(例如,我们有足够的计算资源和良好的数据库连接来访问数据源),我们也应该考虑我们是否想要处理这些失败以及我们希望如何处理它们。我们已经花费了大量时间在训练步骤上,但现在,步骤突然失败了,我们失去了所有的进度。换句话说,我们不希望从头开始重新训练所有模型,这可能会在我们能够将最佳模型的聚合结果交付给用户之前增加相当多的时间。有没有更好的方法来处理这个问题,而又不会对我们的用户体验产生巨大影响?
除了为每个模型训练步骤记录的元数据外,我们还可以保存更多有用的元数据,这些元数据可以用来判断是否值得重新运行所有模型训练步骤。例如,模型准确率随时间的变化表明模型是否被有效地训练。
模型准确率保持稳定甚至下降(如图 6.17 所示,从 30%下降到 27%)可能表明模型已经收敛,继续训练将不再提高模型准确率。在这个例子中,尽管有两个模型训练步骤失败,但不需要从头开始重试第三个模型训练步骤,因为这会导致一个快速收敛但准确率低的模型。另一个可能有用的元数据示例是模型训练完成的百分比(例如,如果我们已经迭代了所有请求的批次和 epoch,完成度为 100%)。

图 6.17 两个模型训练步骤失败且其中一个模型准确率下降的示例工作流程
一旦我们有了关于模型训练步骤的额外元数据,我们就可以了解每个开始模型训练步骤的进展情况。例如,对于图 6.18 中的工作流程,我们可能提前得出结论,第三个模型训练步骤进展非常缓慢(每 30 分钟只完成 1%),这是由于分配的计算资源较少或模型架构更复杂。我们知道,考虑到有限的时间,我们最终可能得到一个准确率很低的模型。因此,我们可以忽略这个模型训练步骤,转而将更多的计算资源分配给其他具有更大潜力的模型训练步骤,这有助于更快地得到更准确的模型。

图 6.18 两个模型训练步骤失败的示例工作流程。其中一个被忽略,因为它进展非常缓慢,考虑到有限的时间,模型可能具有很低的准确率。
记录这些元数据可能有助于我们从端到端机器学习工作流程中的每个失败的步骤中得出更多特定的见解。然后我们可以决定采取适当的策略来处理失败的步骤,以避免浪费计算资源并最小化对现有用户的影响。元数据模式为我们提供了对机器学习管道的深入了解。如果我们在定期运行大量管道的情况下,它们还可以用于搜索、过滤和分析未来每个步骤中产生的工件。例如,我们可能想知道哪些模型表现良好,或者哪些数据集对那些模型贡献最大,基于历史训练指标。
6.3.3 讨论
在机器学习工作流程的各个步骤中,借助元数据模式,我们可以获得额外的见解。然后,如果任何步骤失败,我们可以根据对用户有益的方式做出响应,从而减少步骤失败带来的任何负面影响。
一种常见的元数据类型是模型训练过程中的各种网络性能(mng.bz/D4lR)指标(例如,带宽、吞吐量、延迟)。这类信息对于检测某些工作者遇到的网络性能不佳,从而阻塞整个训练过程非常有用。我们可以关闭速度较慢的工作者,并启动新的工作者以继续训练,前提是底层机器学习框架支持弹性调度和容错(参见第三章)。例如,在图 6.19 中,根据元数据,右侧的工作者具有极高的延迟(是其他工作者的 10 倍),这减缓了整个模型训练过程。理想情况下,这个工作者应该被关闭并重新启动。

图 6.19 一个基于参数服务器的模型训练示例,其中右侧的工作者具有极高的延迟(是其他工作者的 10 倍),这减缓了整个模型训练过程
将元数据模式引入我们的机器学习工作流程的另一个额外好处是,可以使用记录的元数据在各个步骤之间或不同工作流程之间建立关系。例如,现代模型管理工具可以使用记录的元数据帮助用户构建训练模型的谱系,并可视化哪些步骤/因素对模型工件做出了贡献。
6.3.4 练习
-
如果训练步骤由于训练数据源丢失而失败,我们应该怎么办?
-
如果我们查看单个工作者或参数服务器,可以收集哪些类型的元数据?
6.4 练习答案
第 6.2 节
-
不,我们可以在抽象的每个级别应用这种调度策略,例如进程、用户、组等。
-
不,一些机器学习框架支持弹性调度,允许分布式模型训练作业在任何数量的工作者可用时启动,无需等待所有请求的工作者都准备好进行通信。在这种情况下,群组调度不适用。
第 6.3 节
-
在重试模型训练步骤之前,我们应该重新运行数据摄取,因为这种失败是永久的,简单地重试会导致重复失败。
-
模型训练过程中的各种网络性能指标(例如,带宽、吞吐量和延迟)。当我们想要检测工作者遇到的网络性能不佳,从而阻塞整个训练过程时,这类信息非常有用。
摘要
-
与机器学习系统中的操作相关,有多个改进领域,例如作业调度和元数据。
-
各种调度模式,如公平共享调度、优先级调度和成组调度,可以用来防止资源饥饿和避免死锁。
-
我们可以收集元数据,从机器学习工作流程中获得洞察力,并更恰当地处理故障,以减少对用户产生的任何负面影响。
7 项目概述和系统架构
本章涵盖
-
提供我们系统的整体高级设计
-
优化数据摄取组件以处理数据集的多个 epoch
-
决定哪种分布式模型训练策略最能最小化开销
-
为高性能模型服务添加模型服务器副本
-
加速我们机器学习系统的端到端工作流程
在前面的章节中,我们学习了如何选择和应用正确的模式来构建和部署分布式机器学习系统,以获得实际经验来管理和自动化机器学习任务。在第二章中,我介绍了一些可以融入数据摄取的实用模式,通常这是分布式机器学习系统的第一个过程,负责监控传入的数据并执行必要的预处理步骤以准备模型训练。
在第三章中,我们探讨了处理分布式训练组件的一些挑战,并介绍了一些可以融入该组件的实用模式。分布式训练组件是分布式机器学习系统中最关键的部分,也是使系统区别于一般分布式系统的原因。在第四章中,我们涵盖了分布式模型服务系统中涉及到的挑战,并介绍了一些常用的模式。你可以使用副本服务来实现水平扩展,使用分片服务模式来处理大量的模型服务请求。你还学习了如何评估模型服务系统,并确定在现实场景中事件驱动设计是否有益。
在第五章中,我们讨论了机器学习工作流,这是机器学习系统中最基本的部分之一,因为它将机器学习系统中的所有其他组件连接起来。最后,在第六章中,我们讨论了一些可以极大地加速端到端工作流程并减少在系统成为生产就绪之前工程团队与数据科学家或机器学习实践者合作时的维护和沟通努力的运营努力和模式。
在本书的剩余章节中,我们将构建一个端到端机器学习系统来应用之前学到的知识。你将获得实际操作经验来实现我们之前讨论的许多模式。你将学习如何在大规模上解决问题,并将你在笔记本电脑上开发的内容扩展到大型分布式集群。在本章中,我们将介绍项目背景和系统组件。然后,我们将讨论与组件相关的挑战,并讨论我们可以应用以解决这些挑战的模式。
注意,尽管我们不会在本章深入探讨实现细节,但在接下来的章节中,我们将使用几个流行的框架和前沿技术——特别是 TensorFlow、Kubernetes、Kubeflow、Docker 和 Argo Workflows——来构建分布式机器学习工作流程的组件。
7.1 项目概述
对于这个项目,我们将构建一个图像分类系统,该系统从数据源下载原始图像,执行必要的数据清理步骤,在分布式 Kubernetes 集群中构建机器学习模型,然后将训练好的模型部署到模型服务系统中供用户使用。我们还希望建立一个高效且可重用的端到端工作流程。接下来,我将介绍项目背景、整体系统架构和组件。
7.1.1 项目背景
我们将构建一个端到端的机器学习系统来应用我们之前学到的知识。我们将构建一个数据摄取组件,用于下载 Fashion-MNIST 数据集,并构建一个模型训练组件来训练和优化图像分类模型。一旦最终模型训练完成,我们将构建一个高性能的模型服务系统,开始使用训练好的模型进行预测。
如前所述,我们将使用几个框架和技术来构建分布式机器学习工作流程组件。例如,我们将使用 TensorFlow 和 Python 在 Fashion-MNIST 数据集上构建分类模型并进行预测。我们将使用 Kubeflow 在 Kubernetes 集群上运行分布式机器学习模型训练。此外,我们将使用 Argo Workflows 构建一个由分布式机器学习系统的重要组件组成的机器学习管道。这些技术的基础知识将在下一章中介绍,你将在实际项目实现之前通过它们获得实践经验。在下一节中,我们将检查项目的系统组件。
7.1.2 系统组件
图 7.1 是我们将要构建的系统架构图。首先,我们将构建一个数据摄取组件,负责摄取数据并将数据集存储在缓存中,使用第二章中讨论的一些模式。接下来,我们将构建三个不同的模型训练步骤,分别训练不同的模型,并纳入第三章中讨论的集体通信模式。完成模型训练步骤后,我们将构建模型选择步骤,选择最佳模型。选定的最优模型将在接下来的两个步骤中用于模型服务。在模型服务步骤结束时,我们将汇总预测结果并向用户展示。最后,我们希望确保所有这些步骤都是可重复的工作流程的一部分,可以在任何时间、任何环境中执行。
我们将根据图 7.1 中的架构图来构建系统,并深入探讨各个组件的细节。我们还将讨论我们可以使用的模式来解决构建这些组件的挑战。

图 7.1 我们将要构建的端到端机器学习系统的架构图
7.2 数据摄取
对于这个项目,我们将使用第 2.2 节中介绍的 Fashion-MNIST 数据集来构建数据摄取组件,如图 7.2 所示。此数据集包含一个包含 60,000 个示例的训练集和一个包含 10,000 个示例的测试集。每个示例是一个 28 × 28 的灰度图像,代表与 10 个类别标签之一相关的 Zalando 文章图像。回想一下,Fashion-MNIST 数据集是为了作为原始 MNIST 数据集的直接替换而设计的,用于基准测试机器学习算法。它共享相同的图像大小和训练/测试分割的结构。

图 7.2 端到端机器学习系统中的数据摄取组件(深色框)
作为回顾,图 7.3 是 Fashion-MNIST 中所有 10 个类别(T 恤/上衣、裤子、开衫、连衣裙、外套、凉鞋、衬衫、运动鞋、包和踝靴)的图像集合的截图,其中每个类别在截图中占三行。

图 7.3 Fashion-MNIST 数据集中所有 10 个类别(T 恤/上衣、裤子、开衫、连衣裙、外套、凉鞋、衬衫、运动鞋、包和踝靴)的图像集合的截图
图 7.4 是训练集中前几个示例图像的近距离观察,以及它们对应的文本标签。

图 7.4 训练集中前几个示例图像的近距离观察,以及它们对应的文本标签
如果压缩,下载的 Fashion-MNIST 数据集在磁盘上应该只占用 30 MB。一次性将整个下载的数据集加载到内存中很容易。
7.2.1 问题
尽管 Fashion-MNIST 数据集不大,但在将数据集输入模型之前,我们可能需要进行额外的计算,这对于需要额外转换和清理的任务来说是常见的。我们可能想要调整大小、归一化,或将图像转换为灰度。我们还可能想要执行复杂的数学运算,如卷积运算,这可能需要分配大量的额外内存空间。在将整个数据集加载到内存后,我们的可用计算资源可能足够,也可能不足,这取决于分布式集群的大小。
此外,我们从该数据集训练的机器学习模型需要在训练数据集上运行多个 epoch。假设在整个训练数据集上训练一个 epoch 需要 3 小时。如果我们想训练两个 epoch,模型训练所需的时间将翻倍,如图 7.5 所示。

图 7.5 在时间 t0、t1 等处进行多个 epoch 的模型训练的示意图,我们每个 epoch 花费了 3 小时
在现实世界的机器学习系统中,通常需要更多的 epoch,并且依次训练每个 epoch 效率低下。在下一节中,我们将讨论如何解决这种低效问题。
7.2.2 解决方案
让我们看看我们面临的第一大挑战:在机器学习算法中的数学运算可能需要大量的额外内存空间分配,而计算资源可能或可能不足。鉴于我们没有太多的空闲内存,我们不应该直接将整个 Fashion-MNIST 数据集加载到内存中。假设我们想要在数据集上执行的数学运算可以在整个数据集的子集上执行。那么,我们可以使用第二章中引入的批处理模式,该模式将整个数据集的一定数量的数据记录分组到批次中,这些批次将被用来依次在每个批次上训练机器学习模型。
要应用批处理模式,我们首先将数据集分成更小的子集或小批量,加载每个单独的小批量示例图像,对每个批次执行昂贵的数学运算,然后在每个模型训练迭代中只使用一个小批量图像。例如,我们可以对只包含 20 个图像的第一个小批量执行卷积或其他复杂的数学运算,然后将转换后的图像发送到机器学习模型进行模型训练。然后,我们重复相同的流程对剩余的小批量进行操作,同时继续进行模型训练。
由于我们已经将数据集分成许多小子集(小批量),我们可以在对整个数据集执行必要的各种复杂数学运算以实现 Fashion-MNIST 数据集上的准确分类模型时避免任何潜在的内存不足问题。然后,我们可以通过减小小批量的大小来使用这种方法处理更大的数据集。
在批处理模式的帮助下,我们不再担心在数据集导入模型训练时可能出现的内存不足问题。我们不必一次性将整个数据集加载到内存中,而是按批次顺序消耗数据集。例如,如果我们有一个包含 1,000 条记录的数据集,我们可以首先取 500 条记录形成一个批次,然后使用这个批次记录来训练模型。随后,我们可以对剩余的记录重复此批处理和模型训练过程。图 7.6 说明了这个过程,其中原始数据集被分成两个批次并依次处理。第一个批次在时间 t0 被消耗以训练模型,第二个批次在时间 t1 被消耗。

图 7.6 数据集被分为两个批次并依次处理。第一个批次在时间 t0 被消耗以训练模型,第二个批次在时间 t1 被消耗。
现在,让我们解决 7.2.1 节中提到的第二个挑战:如果我们需要训练一个涉及迭代原始数据集多个迭代的机器学习模型,我们希望避免浪费时间。回想一下,在第二章中,我们讨论了缓存模式,这将解决这类问题。借助缓存模式,我们可以极大地加快涉及在相同数据集上多次训练的模型训练过程的重新访问数据集的速度。
对于第一次迭代,我们无法做任何特殊的事情,因为这是机器学习模型第一次看到整个训练数据集。我们可以将训练示例的缓存存储在内存中,使其在需要时重新访问时更快。
假设我们用来训练模型的单个笔记本电脑具有足够的计算资源,例如内存和磁盘空间。一旦机器学习模型消耗了整个数据集的每个训练示例,我们就可以推迟回收,而是将消耗的训练示例保留在内存中。例如,在图 7.7 中,在我们完成第一次迭代的模型拟合后,我们可以为第一次模型训练使用的两个批次存储缓存。

图 7.7 使用缓存在时间 t0、t1 等多次迭代中训练模型的示意图,使得从数据源反复读取变得不必要
然后,我们可以通过直接向模型提供存储在内存中的缓存来开始第二次迭代的模型训练,而不需要为未来的迭代从数据源反复读取。接下来,我们将讨论我们将在项目中构建的模型训练组件。
7.2.3 练习
-
我们在哪里存储缓存?
-
当 Fashion-MNIST 数据集变得很大时,我们能否使用批处理模式?
7.3 模型训练
在上一节中,我们讨论了我们正在构建的系统中的数据摄取组件,以及我们如何使用缓存和批处理模式来处理大型数据集并使系统更高效。接下来,让我们讨论我们正在构建的模型训练组件。图 7.8 是整体架构中模型训练组件的示意图。

图 7.8 端到端机器学习系统中的模型训练组件(深色框)
在图中,三个不同的模型训练步骤之后跟着一个模型选择步骤。这些模型训练步骤可以训练三个不同的模型,它们相互竞争以获得更好的统计性能。专门的模型选择步骤随后选择最佳模型,该模型最终将在端到端机器学习工作流程的后续组件中使用。
在下一节中,我们将更仔细地查看图 7.8 中的模型训练组件,并讨论实现此组件时可能遇到的问题。
7.3.1 问题
在第三章中,我介绍了参数服务器和集体通信模式。当模型太大而无法适应单台机器时,参数服务器模式很有用,例如用于标记 800 万 YouTube 视频中的实体(第 3.2 节)。集体通信模式在通信开销显著时,有助于加速中等规模模型的训练过程。我们应该为我们的模型训练组件选择哪种模式?
7.3.2 解决方案
通过参数服务器的帮助,我们可以有效地解决构建可能不适合单台机器的极大型机器学习模型的挑战。即使模型太大而无法适应单台机器,我们仍然可以使用参数服务器高效地成功训练模型。例如,图 7.9 是使用多个参数服务器的参数服务器模式架构图。每个工作者节点处理数据集的一个子集,执行每个神经网络层所需的计算,并将计算出的梯度发送到更新存储在参数服务器中的一个模型分区。

图 7.9 多参数服务器机器学习训练组件
由于所有工作者都以异步方式执行计算,模型分区的每个工作者节点用于计算梯度的分区可能不是最新的。例如,两个工作者在向同一个参数服务器发送梯度时可能会互相阻塞,这使得及时收集计算出的梯度变得困难,并需要一种策略来解决阻塞问题。不幸的是,在包含参数服务器的现实世界分布式训练系统中,多个工作者可能同时发送梯度,因此必须解决许多阻塞通信。
当决定工作者数量与参数服务器数量之间的最佳比例时,又出现了一个挑战。例如,许多工作者同时向同一个参数服务器发送梯度;问题变得更加严重,最终,不同工作者或参数服务器之间的阻塞通信成为瓶颈。
现在,让我们回到我们的原始应用,即 Fashion-MNIST 分类模型。我们正在构建的模型并不像大型推荐系统模型那样大;如果我们给机器足够的计算资源,它就可以轻松地适应单台机器。它以压缩形式只有 30 MB。因此,集体通信模型非常适合我们正在构建的系统。
现在,没有参数服务器的情况下,每个工作者节点存储整个模型参数集的副本,如图 7.10 所示。我之前提到,每个工作者消费部分数据并计算更新存储在本工作者节点上的模型参数所需的梯度(见第三章)。我们希望在所有工作者节点成功完成梯度计算后立即聚合所有梯度。我们还想确保每个工作者的整个模型参数集基于聚合的梯度进行更新。换句话说,每个工作者应该存储一个与更新后的模型完全相同的副本。

图 7.10 展示了仅包含工作者节点的分布式模型训练组件,其中每个工作者存储整个模型参数集的副本,并消费数据分区来计算梯度
回到图 7.8 中的架构图,每个模型训练步骤都使用集体通信模式,利用底层网络基础设施执行 allreduce 操作,在多个工作者之间通信梯度。集体通信模式还允许我们在分布式环境中训练多个中等规模的机器学习模型。一旦模型训练完成,我们可以启动一个单独的过程来挑选出将被用于模型服务的最佳模型。这一步骤相当直观,我将把实现细节推迟到第九章。在下一节中,我们将讨论系统中的模型服务组件。
7.3.3 练习
-
为什么参数服务器模式不适合我们的模型?
-
使用集体通信模式时,每个工作者是否存储模型的不同部分?
7.4 模型服务
我们已经讨论了我们正在构建的系统中的数据摄取和模型训练组件。接下来,让我们讨论模型服务器组件,这对于最终用户体验至关重要。图 7.11 显示了整体架构中的模型服务训练组件。

图 7.11 展示了端到端机器学习系统中的模型服务组件(深色框)
接下来,让我们看看在开始构建此组件时可能会遇到的一个潜在问题和其解决方案。
7.4.1 问题
模型服务系统需要接收用户上传的原始图像,并将请求发送到模型服务器,使用训练好的模型进行推理。这些模型服务请求正在排队等待模型服务器处理。
如果模型服务系统是一个单节点服务器,它只能基于先到先得的原则服务有限数量的模型服务请求。随着实际应用中请求数量的增长,当用户必须等待很长时间才能收到模型服务结果时,用户体验会受到影响。换句话说,所有请求都在等待被模型服务系统处理,但计算资源仅限于这个单节点。我们如何构建一个更高效的模型服务系统?
7.4.2 解决方案
上一节为第四章中讨论的复制服务模式提供了一个完美的用例。我们的模型服务系统接收用户上传的图像并发送请求到模型服务器。此外,与简单的单服务器设计不同,系统具有多个模型服务器副本以异步处理模型服务请求。每个模型服务器副本处理单个请求,从模型训练组件检索先前训练的分类模型,并对请求中不存在于 Fashion-MNIST 数据集中的图像进行分类。
通过使用复制服务模式,我们可以轻松地将模型服务器通过添加模型服务器副本到单服务器模型服务系统中进行扩展。新的架构如图 7.12 所示。模型服务器副本可以同时处理多个请求,因为每个副本可以独立处理单个模型服务请求。

图 7.12 复制模型服务服务的系统架构
在引入它们之后,来自用户的多个模型服务请求同时发送到模型服务器副本。我们还需要定义请求和模型服务器副本之间的明确映射关系,这决定了哪些请求由哪个模型服务器副本处理。
为了在副本之间分配模型服务器请求,我们需要添加一个额外的负载均衡器层。例如,负载均衡器从我们的用户那里接收多个模型服务请求。然后,它将请求均匀地分配给模型服务器副本,这些副本负责处理单个请求,包括模型检索和请求中的新数据的推理。图 7.13 说明了这个过程。

图 7.13 展示了负载均衡器如何将请求均匀地分配到模型服务器副本的图表
负载均衡器使用不同的算法来确定哪个请求发送到哪个特定的模型服务器副本。负载均衡的示例算法包括轮询、最少连接方法和哈希。
注意,从我们原始的架构图 7.11 中,模型服务有两个独立的步骤,每个步骤使用不同的模型。每个模型服务步骤由一个模型服务服务及其多个副本组成,以处理不同模型的模型服务流量。
7.4.3 练习
- 当我们没有在模型服务系统中包含负载均衡器时会发生什么?
7.5 端到端工作流
现在我们已经研究了单个组件,让我们看看如何以可扩展和高效的方式组合所有这些组件的端到端工作流。我们还将把第五章中的一些模式融入到工作流中。图 7.14 是我们正在构建的端到端工作流的示意图。

图 7.14 我们将构建的端到端机器学习系统的架构图
我们将不再关注单个组件,而是将查看整个机器学习系统,该系统将所有组件以端到端工作流的形式连接在一起。
7.5.1 问题
首先,Fashion-MNIST 数据集是静态的,不会随时间变化。然而,为了设计一个更现实化的系统,让我们假设我们将定期手动更新 Fashion-MNIST 数据集。每当更新发生时,我们可能希望重新运行整个机器学习工作流,以训练包含新数据的全新机器学习模型。换句话说,每次发生变化时,我们都需要执行数据摄取步骤。与此同时,当数据集未更新时,我们想要尝试新的机器学习模型。因此,我们仍然需要执行整个工作流,包括数据摄取步骤。数据摄取步骤通常非常耗时,尤其是对于大型数据集。有没有一种方法可以使这个工作流更高效?
第二,我们希望构建一个机器学习工作流,该工作流能够训练不同的模型,然后选择最佳模型,该模型将在模型服务中使用来自两个模型的知识来生成预测。由于现有机器学习工作流中每个模型训练步骤完成时间的差异,每个后续步骤(如模型选择和模型服务)的开始都依赖于前一步的完成。然而,工作流中步骤的这种顺序执行非常耗时,并阻塞了其他步骤。例如,假设某个模型训练步骤的完成时间比其他步骤长得多。接下来的模型选择步骤只能在长时间运行的模型训练步骤完成后才能开始执行。因此,整个工作流因这一特定步骤而延迟。有没有一种方法可以加速这个工作流,使其不会受到单个步骤持续时间的影响?
7.5.2 解决方案
对于第一个问题,我们可以使用第五章中提到的步骤记忆化模式。回想一下,步骤记忆化可以帮助系统决定是否执行或跳过某个步骤。借助步骤记忆化,工作流可以识别出那些可以跳过而不需要重新执行的重载步骤,从而大大加速端到端工作流的执行。
例如,图 7.15 包含一个简单的流程,只有在我们知道数据集已更新时才会执行数据摄取步骤。换句话说,如果新数据没有更新,我们不想重新摄取已经收集的数据。

图 7.15 当数据集未更新时跳过数据摄取步骤的示意图
可以使用许多策略来确定数据集是否已更新。使用预定义的策略,我们可以有条件地重建机器学习工作流程,并控制是否希望重新执行数据摄取步骤,如图 7.16 所示。
缓存是识别数据集是否已更新的方法之一。由于我们假设我们的 Fashion-MNIST 数据集正在按照固定的时间表(例如,每月一次)定期更新,我们可以创建一个基于时间的缓存,该缓存存储了已摄取和清理的数据集的位置(假设数据集位于远程数据库中)以及其最后更新时间戳。
正如图 7.16 所示,工作流程中的数据摄取步骤将根据最后更新时间戳是否在特定窗口内动态构建和执行。例如,如果时间窗口设置为两周,那么如果数据在过去两周内更新过,我们就认为摄取的数据是新鲜的。数据摄取步骤将被跳过,接下来的模型训练步骤将使用缓存中的位置处已经摄取的数据集。时间窗口可以用来控制缓存可以有多旧,在我们认为数据集足够新鲜可以直接用于模型训练而不是从头开始重新摄取数据之前。

图 7.16 工作流程已被触发。我们通过访问缓存来检查数据是否在过去两周内更新过。如果数据是新鲜的,我们可以跳过不必要的数据摄取步骤,并直接执行模型训练步骤。
现在,让我们看看第二个问题:步骤的顺序执行会阻塞工作流程中的后续步骤,这是低效的。第五章中介绍的同步和异步模式可以有所帮助。
当一个短运行模型训练步骤完成时——例如,图 7.17 中的模型训练步骤 2——我们就成功获得了一个训练好的机器学习模型。实际上,我们可以在模型服务系统中直接使用这个已经训练好的模型,而无需等待模型训练的其他步骤完成。因此,当我们在工作流程中的某个步骤训练了一个模型后,用户就能立即从包含视频的模型服务请求中看到图像分类的结果。当第二个模型训练步骤(图 7.17,模型训练步骤 3)完成后,两个训练好的模型被发送到模型服务。现在,用户能够从两个模型聚合的结果中受益。

图 7.17 在第二个模型训练步骤完成后,我们可以直接将两个训练好的模型传递给模型服务。将展示给用户的将是聚合的推理结果,而不仅仅是第一个模型的输出。
因此,我们可以继续使用训练好的模型进行模型选择和模型服务;同时,长时间运行的模型训练步骤仍在进行。换句话说,它们在不依赖于彼此完成的情况下异步执行。工作流程可以继续进行并执行下一个步骤,在之前的步骤完成之前。长时间运行的模型训练步骤将不再阻塞整个工作流程。相反,它可以使用来自短时间运行的模型训练步骤的已训练模型继续在模型服务系统中使用。因此,它可以开始处理用户的模型服务请求。
7.5.3 练习
-
哪个组件最能从步骤记忆化中受益?
-
如果一个步骤的工作流程已被触发再次运行,我们如何判断该步骤的执行是否可以跳过?
7.6 练习答案
第 7.2 节
-
在内存中
-
是
第 7.3 节
-
工作器和参数服务器之间存在阻塞通信。
-
不,每个工作器存储的是模型的确切相同副本。
第 7.4 节
- 我们无法在副本之间平衡或分配模型服务请求。
第 7.5 节
-
数据摄入组件
-
使用步骤缓存中的元数据
摘要
-
数据摄入组件使用缓存模式来加速处理数据集多个 epoch 的处理。
-
模型训练组件使用集体通信模式来避免工作器和参数服务器之间潜在的通信开销。
-
我们可以使用模型服务器副本,因为每个副本可以独立处理模型服务请求,所以它们能够同时处理许多请求。
-
我们可以将所有组件链接成一个工作流程,并使用缓存来有效地跳过耗时组件,如数据摄入。
8 相关技术的概述
本章涵盖
-
熟悉使用 TensorFlow 进行模型构建
-
理解 Kubernetes 上的关键术语
-
使用 Kubeflow 运行分布式机器学习工作负载
-
使用 Argo Workflows 部署容器原生工作流程
在上一章中,我们了解了项目背景和系统组件,以理解我们实现每个组件的策略。我们还讨论了与每个组件相关的挑战,并讨论了我们将应用的解决模式。如前所述,我们将在第九章,即本书的最后一章中深入探讨项目的实现细节。然而,由于项目将使用不同的技术,且难以即时涵盖所有基础知识,因此在本章中,你将学习四种技术(TensorFlow、Kubernetes、Kubeflow 和 Argo Workflows)的基本概念,并获得实践经验。
这四种技术各有不同的用途,但都将用于在第九章中实现最终项目。TensorFlow 将用于数据处理、模型构建和评估。我们将使用 Kubernetes 作为我们的核心分布式基础设施。在此基础上,我们将使用 Kubeflow 将分布式模型训练作业提交到 Kubernetes 集群,并使用 Argo Workflows 构建和提交端到端的机器学习工作流程。
8.1 TensorFlow:机器学习框架
TensorFlow 是一个端到端的机器学习平台。它已被广泛采用于学术界和工业界,用于各种应用和用例,例如图像分类、推荐系统、自然语言处理等。TensorFlow 具有高度的便携性和可部署性,可以在不同的硬件上运行,并支持多种语言。
TensorFlow 拥有庞大的生态系统。以下是一些该生态系统中的重点项目:
-
TensorFlow.js 是一个用于 JavaScript 的机器学习库。用户可以直接在浏览器或 Node.js 中使用机器学习。
-
TensorFlow Lite 是一个移动库,用于在移动设备、微控制器和其他边缘设备上部署模型。
-
TFX 是一个端到端平台,用于部署生产机器学习管道。
-
TensorFlow Serving 是一个灵活、高性能的机器学习模型服务系统,专为生产环境设计。
-
TensorFlow Hub 是一个存储库,包含经过训练的机器学习模型,可用于微调和部署到任何地方。只需几行代码即可重用 BERT 和 Faster R-CNN 等训练模型。
更多信息可以在 TensorFlow GitHub 组织(github.com/tensorflow)中找到。我们将在模型服务组件中使用 TensorFlow Serving。在下一节中,我们将通过一些 TensorFlow 的基本示例来训练机器学习模型,使用 MNIST 数据集进行本地训练。
8.1.1 基础知识
让我们先为我们将要使用的示例安装 Python 3 的 Anaconda。Anaconda(www.anaconda.com)是 Python 和 R 编程语言的科学计算发行版,旨在简化包管理和部署。该发行版包括适用于 Windows、Linux 和 macOS 的数据科学包。一旦安装了 Anaconda,请在您的控制台中使用以下命令安装 Python 3.9 的 Conda 环境。
列表 8.1 创建 Conda 环境
> conda create --name dist-ml python=3.9 -y
接下来,我们可以使用以下代码激活此环境。
列表 8.2 激活 Conda 环境
> conda activate dist-ml
然后,我们可以在这个 Python 环境中安装 TensorFlow。
列表 8.3 安装 TensorFlow
> pip install --upgrade pip
> pip install tensorflow==2.10.0
如果您遇到任何问题,请参阅安装指南(www.tensorflow.org/install)。
在某些情况下,您可能需要卸载现有的 NumPy 并重新安装它。
列表 8.4 安装 NumPy
> pip install numpy --ignore-installed
如果您使用的是 Mac,请查看 Metal 插件以实现加速(developer.apple.com/metal/tensorflow-plugin/)。
一旦我们成功安装了 TensorFlow,我们就可以从一个基本的图像分类示例开始!让我们首先加载并预处理我们的简单 MNIST 数据集。回想一下,MNIST 数据集包含从 0 到 9 的手写数字图像。每一行代表特定手写数字的图像,如图 8.1 所示。

图 8.1 从 0 到 9 的手写数字示例图像,其中每一行代表特定手写数字的图像
Keras API(tf.keras)是 TensorFlow 中模型训练的高级 API,我们将用它来加载内置数据集以及模型训练和评估。
列表 8.5 加载 MNIST 数据集
> import tensorflow as tf
> (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()
如果我们没有指定路径,函数 load_data()将使用默认路径保存 MNIST 数据集。此函数将返回训练和测试图像及标签的 NumPy 数组。我们将数据集分为训练和测试,以便我们可以在示例中运行模型训练和评估。
NumPy 数组是 Python 科学计算生态系统中常见的数据类型。它描述多维数组,并具有三个属性:数据、形状和数据类型。让我们以我们的训练图像为例。
列表 8.6 检查数据集
> x_train.data
<memory at 0x16a392310>
> x_train.shape
(60000, 28, 28)
> x_train.dtype
dtype('uint8')
> x_train.min()
0
> x_train.max()
255
x_train 是一个 60,000 × 28 × 28 的三维数组。数据类型是 uint8,范围从 0 到 255。换句话说,这个对象包含 60,000 个 28 × 28 分辨率的灰度图像。
接下来,我们可以在原始图像上执行一些特征预处理。由于许多算法和模型对特征的规模很敏感,我们通常将特征中心化和缩放到[0, 1]或[-1, 1]的范围。在我们的例子中,我们可以通过将图像除以 255 来实现这一点。
列表 8.7 预处理函数
def preprocess(ds):
return ds / 255.0
x_train = preprocess(x_train)
x_test = preprocess(x_test)
> x_train.dtype
dtype('float64')
> x_train.min()
0.0
> x_train.max()
1.0
在对训练集和测试集中的图像进行预处理后,我们可以实例化一个简单的多层神经网络模型。我们使用 tf.keras 定义模型架构。首先,我们使用 Flatten 将二维图像扩展成一维数组,指定输入形状为 28 × 28。第二层是密集连接层,并使用 'relu' 激活函数引入一些非线性。第三层是一个 dropout 层,用于减少过拟合并使模型更具泛化能力。由于手写数字由 0 到 9 的 10 个不同数字组成,我们的最后一层是密集连接层,用于 10 类分类,并使用 softmax 激活。
列表 8.8 顺序模型定义
model = tf.keras.models.Sequential([
tf.keras.layers.Flatten(input_shape=(28, 28)),
tf.keras.layers.Dense(128, activation='relu'),
tf.keras.layers.Dropout(0.2),
tf.keras.layers.Dense(10, activation='softmax')
])
在我们定义了模型架构之后,我们需要指定三个不同的组件:评估指标、损失函数和优化器。
列表 8.9 使用优化器、损失函数和优化器编译模型
model.compile(optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
然后,我们可以通过以下方式开始我们的模型训练,包括五个周期以及评估。
列表 8.10 使用训练数据训练模型
model.fit(x_train, y_train, epochs=5)
model.evaluate(x_test, y_test)
我们应该在日志中看到训练进度:
Epoch 1/5
1875/1875 [======] - 11s 4ms/step - loss: 0.2949 - accuracy: 0.9150
Epoch 2/5
1875/1875 [======] - 9s 5ms/step - loss: 0.1389 - accuracy: 0.9581
Epoch 3/5
1875/1875 [======] - 9s 5ms/step - loss: 0.1038 - accuracy: 0.9682
Epoch 4/5
1875/1875 [======] - 8s 4ms/step - loss: 0.0841 - accuracy: 0.9740
Epoch 5/5
1875/1875 [======] - 8s 4ms/step - loss: 0.0707 - accuracy: 0.9779
10000/10000 [======] - 0s - loss: 0.0726 - accuracy: 0.9788
并且模型评估的日志应该看起来像以下这样:
313/313 [======] - 1s 4ms/step - loss: 0.0789 - accuracy: 0.9763
[0.07886667549610138, 0.976300060749054]
我们应该观察到,在训练过程中损失下降时,训练数据的准确率增加到 97.8%。最终训练好的模型在测试数据上的准确率为 97.6%。由于建模过程中的随机性,你的结果可能会有所不同。
在我们训练好模型并对它的性能感到满意后,我们可以使用以下代码保存它,这样我们下次就不需要从头开始重新训练。
列表 8.11 保存训练好的模型
model.save('my_model.h5')
此代码将模型保存为当前工作目录下的文件 my_model.h5。当我们启动一个新的 Python 会话时,我们可以导入 TensorFlow 并从 my_model.h5 文件中加载模型对象。
列表 8.12 加载已保存的模型
import tensorflow as tf
model = tf.keras.models.load_model('my_model.h5')
我们已经学习了如何使用 TensorFlow 的 Keras API 训练一个具有单个超参数集的模型。这些超参数在整个训练过程中保持不变,并直接影响机器学习程序的性能。让我们学习如何使用 Keras Tuner 调整 TensorFlow 程序的超参数(keras.io/keras_tuner/)。首先,安装 Keras Tuner 库。
列表 8.13 安装 Keras Tuner 包
pip install -q -U keras-tuner
一旦安装完成,你应该能够导入所有必需的库。
列表 8.14 导入必要的包
import tensorflow as tf
from tensorflow import keras
import keras_tuner as kt
我们将使用相同的 MNIST 数据集和预处理函数来演示超参数调整示例。然后,我们将模型定义包装成一个 Python 函数。
列表 8.15 使用 TensorFlow 和 Keras Tuner 构建模型的功能
def model_builder(hp):
model = keras.Sequential()
model.add(keras.layers.Flatten(input_shape=(28, 28)))
hp_units = hp.Int('units', min_value=32, max_value=512, step=32)
model.add(keras.layers.Dense(units=hp_units, activation='relu'))
model.add(keras.layers.Dense(10))
hp_learning_rate = hp.Choice('learning_rate', values=[1e-2, 1e-3, 1e-4])
model.compile(optimizer=keras.optimizers.Adam(learning_rate=hp_learning_rate),
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=['accuracy'])
return model
此代码基本上与我们之前用于训练具有单个超参数集的模型相同,不同之处在于我们还定义了 hp_units 和 hp_learning_rate 对象,这些对象用于我们的密集层和优化器。
hp_units 对象实例化一个整数,该整数将在 32 和 512 之间调整,并用作第一个密集连接层的单元数。hp_learning_rate 对象将调整用于 adam 优化器的学习率,该学习率将从以下值中选择:0.01、0.001 或 0.0001。
一旦定义了模型构建器,我们就可以实例化我们的调优器。我们可以使用几种调优算法(例如,随机搜索、贝叶斯优化、Hyperband)。这里我们使用 hyperband 调优算法。它使用自适应资源分配和早期停止来更快地收敛到高性能模型。
列表 8.16 Hyperband 模型调优器
tuner = kt.Hyperband(model_builder,
objective='val_accuracy',
max_epochs=10,
factor=3,
directory='my_dir',
project_name='intro_to_kt')
我们使用验证准确率作为目标,在模型调优期间最大迭代次数为 10。
为了减少过拟合,我们可以创建一个 EarlyStopping 回调,一旦模型达到验证损失的阈值,就停止训练。如果你已经启动了一个新的 Python 会话,请确保重新将数据集加载到内存中。
列表 8.17 EarlyStopping 回调
early_stop = tf.keras.callbacks.EarlyStopping(
monitor='val_loss', patience=4)
现在我们可以通过 tuner.search() 开始我们的超参数搜索。
列表 8.18 带有早期停止的超参数搜索
tuner.search(x_train, y_train,
epochs=30, validation_split=0.2,
callbacks=[early_stop])
一旦搜索完成,我们可以识别最佳超参数,并在数据上训练模型 30 个周期。
列表 8.19 获取最佳超参数并训练模型
best_hps = tuner.get_best_hyperparameters(num_trials=1)[0]
model = tuner.hypermodel.build(best_hps)
model.fit(x_train, y_train, epochs=50, validation_split=0.2)
当我们在测试数据上评估模型时,我们应该看到它比没有超参数调整的基线模型性能更好。
列表 8.20 在测试数据上评估模型
model.evaluate(x_test, y_test)
你已经学会了如何在单机上运行 TensorFlow。为了最大限度地利用 TensorFlow,模型训练过程应该在分布式集群中运行,这就是 Kubernetes 发挥作用的地方。在下一节中,我将介绍 Kubernetes 并提供基础知识的实战示例。
8.1.2 练习
-
你可以直接使用之前保存的模型进行模型评估吗?
-
除了使用 Hyperband 调优算法,你能尝试随机搜索算法吗?
8.2 Kubernetes:分布式容器编排系统
Kubernetes(也称为 K8s)是一个用于自动化容器化应用程序部署、扩展和管理的开源系统。它抽象了复杂的容器管理,并为不同计算环境中的容器编排提供了声明性配置。
容器被分组为特定应用的逻辑单元,以便于管理和发现。Kubernetes 建立在谷歌 16 年以上的生产工作负载经验之上,结合了社区中最佳的想法和实践。其主要设计目标是使部署和管理复杂的分布式系统变得容易,同时仍然能够从容器带来的改进利用率中受益。它是开源的,这给了社区利用本地、混合或公共云基础设施的自由,并允许你轻松地将工作负载迁移到需要的地方。
Kubernetes 的设计旨在在不增加你的运维团队的情况下进行扩展。图 8.2 是 Kubernetes 及其组件的架构图。然而,我们不会讨论这些组件,因为它们不是本书的重点。不过,我们将使用 kubectl(位于图左侧),Kubernetes 的命令行界面,来与 Kubernetes 集群交互并获取我们感兴趣的信息。

图 8.2 Kubernetes 的架构图
我们将通过一些基本概念和示例来构建我们的知识,并为 Kubeflow 和 Argo Workflows 的后续章节做准备。
8.2.1 基础知识
首先,让我们设置一个本地 Kubernetes 集群。我们将使用 k3d(k3d.io)来引导本地集群。k3d 是一个轻量级的包装器,用于在 Docker 中运行 Rancher Lab 提供的最小 Kubernetes 发行版 k3s。k3d 使得在 Docker 中创建单节点或多节点 k3s 集群以进行需要 Kubernetes 集群的本地开发变得非常容易。让我们通过 k3s 创建一个名为 distml 的 Kubernetes 集群。
列表 8.21 创建本地 Kubernetes 集群
> k3d cluster create distml --image rancher/k3s:v1.25.3-rc3-k3s1
我们可以通过以下列表获取我们创建的集群的节点列表。
列表 8.22 获取集群中节点的列表
> kubectl get nodes
NAME STATUS ROLES AGE VERSION
K3d-distml-server-0 Ready control-plane,master 1m v1.25.3+k3s1
在这种情况下,节点是在 1 分钟前创建的,我们正在运行 k3s 发行版的 v1.25.3+k3s1 版本。状态为就绪,这样我们就可以进行下一步了。
我们还可以通过 kubectl describe node k3d-distml-server-0 查看节点的详细信息。例如,标签和系统信息包含有关操作系统及其架构的信息,是否该节点是主节点等信息:
Labels: beta.kubernetes.io/arch=arm64
beta.kubernetes.io/instance-type=k3s
beta.kubernetes.io/os=linux
kubernetes.io/arch=arm64
kubernetes.io/hostname=k3d-distml-server-0
kubernetes.io/os=linux
node-role.kubernetes.io/control-plane=true
node-role.kubernetes.io/master=true
node.kubernetes.io/instance-type=k3s
System Info:
Machine ID:
System UUID:
Boot ID: 73db7620-c61d-432c-a1ab-343b28ab8563
Kernel Version: 5.10.104-linuxkit
OS Image: K3s dev
Operating System: linux
Architecture: arm64
Container Runtime Version: containerd://1.5.9-k3s1
Kubelet Version: v1.22.7+k3s1
Kube-Proxy Version: v1.22.7+k3s1
The node’s addresses are shown as part of it:
Addresses:
InternalIP: 172.18.0.3
Hostname: k3d-distml-server-0
The capacity of the node is also available,
indicating how much computational resources are there:
Capacity:
cpu: 4
ephemeral-storage: 61255492Ki
hugepages-1Gi: 0
hugepages-2Mi: 0
hugepages-32Mi: 0
hugepages-64Ki: 0
memory: 8142116Ki
pods: 110
然后,我们将在该集群中创建一个名为 basics 的命名空间用于我们的项目。Kubernetes 中的命名空间提供了一种机制,用于在单个集群内隔离资源组(见mng.bz/BmN1)。资源名称需要在命名空间内是唯一的,但不同命名空间之间不需要唯一。以下示例将在这个单一命名空间内。
列表 8.23 创建新的命名空间
> kubectl create ns basics
一旦集群和命名空间设置完成,我们将使用一个名为 kubectx 的便捷工具来帮助我们检查和在不同命名空间和集群之间导航 (github.com/ahmetb/kubectx). 注意,这个工具在日常使用 Kubernetes 时不是必需的,但它应该会使开发者更容易与 Kubernetes 一起工作。例如,我们可以通过以下列表获取可以连接到的集群和命名空间列表。
列表 8.24 切换上下文和命名空间
> kubectx
d3d-k3s-default
k3d-distml
> kubens
default
kube-system
kube-public
kube-node-lease
basics
例如,我们可以通过以下列表切换到 distml 集群和刚刚创建的 basics 命名空间:k3d-distml 上下文。
列表 8.25 激活上下文
> kubectx k3d-distml
Switched to context "k3d-distml".
> kubens basics
Active namespace is "basics".
当与多个集群和命名空间一起工作时,切换上下文和命名空间通常是必需的。我们在这个章节中使用基本命名空间来运行示例,但在下一章中,我们将切换到另一个专门为我们项目设置的命名空间。
接下来,我们将创建一个 Kubernetes Pod。Pod 是您在 Kubernetes 中可以创建和管理的最小可部署计算单元。一个 Pod 可能包含一个或多个容器,具有共享的存储和网络资源以及运行容器的规范。Pod 的内容始终位于同一位置,并具有相同的调度,在共享上下文中运行。Pod 的概念模拟了一个特定应用的“逻辑主机”,这意味着它包含一个或多个相对紧密耦合的应用容器。在非云环境中,在同一物理或虚拟机上执行的应用程序类似于在相同逻辑主机上执行的云应用程序。换句话说,Pod 类似于具有共享命名空间和共享文件系统卷的一组容器。
以下列表提供了一个示例 Pod,该 Pod 运行 whalesay 镜像以打印出“hello world”消息。我们将以下 Pod 规范保存在名为 hello-world.yaml 的文件中。
列表 8.26 一个示例 Pod
apiVersion: v1
kind: Pod
metadata:
name: whalesay
spec:
containers:
- name: whalesay
image: docker/whalesay:latest
command: [cowsay]
args: ["hello world"]
要创建 Pod,请运行以下命令。
列表 8.27 在集群中创建示例 Pod
> kubectl create -f basics/hello-world.yaml
pod/whalesay created
然后,我们可以通过检索 Pod 列表来检查 Pod 是否已创建。请注意,pods 是复数形式,因此我们可以获取创建的所有 Pod 的完整列表。稍后我们将使用单数形式来获取这个特定 Pod 的详细信息。
列表 8.28 获取集群中 Pod 列表
> kubectl get pods
NAME READY STATUS RESTARTS AGE
whalesay 0/1 Completed 2 (20s ago) 37s
Pod 状态为 Completed,因此我们可以查看 whalesay 容器中打印的内容,如下所示。
列表 8.29 检查 Pod 日志
> kubectl logs whalesay
_____________
< hello world >
-------------
\
\
\
## .
## ## ## ==
## ## ## ## ===
/""""""""""""""""___/ ===
~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~
\______ o __/
\ \ __/
\____\______/
我们还可以通过 kubectl 获取 Pod 的原始 YAML。请注意,我们在这里使用 -o yaml 来获取纯 YAML 格式,但还支持其他格式,如 JSON。我们使用单数 pod 来获取这个特定 Pod 的详细信息,而不是获取现有 Pod 的完整列表,如前所述。
列表 8.30 获取原始 Pod YAML
> kubectl get pod whalesay -o yaml
apiVersion: v1
kind: Pod
metadata:
creationTimestamp: "2022-10-22T14:30:19Z"
name: whalesay
namespace: basics
resourceVersion: "830"
uid: 8e5e13f9-cd58-45e8-8070-c6bbb2dddb6e
spec:
containers:
- args:
- hello world
command:
- cowsay
image: docker/whalesay:latest
imagePullPolicy: Always
name: whalesay
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /var/run/secrets/kubernetes.io/serviceaccount
name: kube-api-access-x826t
readOnly: true
dnsPolicy: ClusterFirst
enableServiceLinks: true
nodeName: k3d-distml-server-
<...truncated...>
volumes:
- name: kube-api-access-x826t
projected:
defaultMode: 420
sources:
- serviceAccountToken:
expirationSeconds: 3607
path: token
- configMap:
items:
- key: ca.crt
path: ca.crt
name: kube-root-ca.crt
- downwardAPI:
items:
- fieldRef:
apiVersion: v1
fieldPath: metadata.namespace
path: namespace
status:
conditions:
- lastProbeTime: null
lastTransitionTime: "2022-10-22T14:30:19Z"
status: "True"
type: Initialized
- lastProbeTime: null
lastTransitionTime: "2022-10-22T14:30:19Z"
message: 'containers with unready status: [whalesay]'
reason: ContainersNotReady
status: "False"
type: Ready
你可能会惊讶,我们为创建 Pod 所使用的原始 YAML 中添加了多少额外的内容,比如状态和条件。这些附加信息通过 Kubernetes 服务器附加和更新,以便客户端应用程序知道 Pod 的当前状态。尽管我们没有明确指定命名空间,但由于我们使用了 kubens 命令设置了当前命名空间,Pod 是在基本命名空间中创建的。
Kubernetes 的基础知识就到这里!在下一节中,我们将学习如何使用 Kubeflow 在我们刚刚设置的本地 Kubernetes 集群中运行分布式模型训练作业。
8.2.2 练习
-
你如何获取 Pod 的 JSON 格式信息?
-
Pod 是否可以包含多个容器?
8.3 Kubeflow:在 Kubernetes 上的机器学习工作负载
Kubeflow 项目致力于使在 Kubernetes 上部署机器学习工作流程变得简单、可移植和可扩展。Kubeflow 的目标不是重新创建其他服务,而是为将一流的开源机器学习系统部署到各种基础设施提供一种简单直接的方式。无论你在哪里运行 Kubernetes,都应该能够运行 Kubeflow。我们将使用 Kubeflow 将分布式机器学习模型训练作业提交到 Kubernetes 集群。
让我们先看看 Kubeflow 提供了哪些组件。图 8.3 是一个包含主要组件的图表。

图 8.3 Kubeflow 的主要组件
Kubeflow Pipelines (KFP; github.com/kubeflow/pipelines)提供了 Python SDK,使机器学习管道更容易使用。它是一个使用 Docker 容器构建和部署可移植和可扩展机器学习工作流程的平台。KFP 的主要目标包括以下内容:
-
ML 工作流程的端到端编排
-
通过可重用组件和管道实现管道可组合性
-
简单管理、跟踪和可视化管道定义、运行、实验和机器学习工件
-
通过缓存消除冗余执行来有效利用计算资源
-
通过平台中立的 IR YAML 管道定义实现跨平台管道可移植性
KFP 使用 Argo Workflows 作为后端工作流程引擎,我将在下一节介绍它,我们将直接使用 Argo Workflows 而不是使用像 KFP 这样的高级包装器。ML 元数据项目已合并到 KFP 中,并作为在 KFP 中编写的机器学习工作流程中产生的元数据的后端。
接下来是 Katib (github.com/kubeflow/katib)。Katib 是一个针对自动化机器学习的 Kubernetes 原生项目。Katib 支持超参数调整、早期停止和神经架构搜索。Katib 对机器学习框架是中立的。它可以调整用户选择任何语言的任何应用程序的超参数,并原生支持许多机器学习框架,如 TensorFlow、Apache MXNet、PyTorch、XGBoost 等。Katib 可以使用任何 Kubernetes 自定义资源执行训练作业,并自带对 Kubeflow 训练操作员、Argo 工作流、Tekton 流水线等的支持。图 8.4 是执行实验跟踪的 Katib UI 的截图。

图 8.4 执行实验跟踪的 Katib UI 的截图
KServe (github.com/kserve/kserve) 是作为 Kubeflow 项目的一部分诞生的,之前被称为 KFServing。KServe 为在任意框架上提供机器学习模型服务提供了 Kubernetes 自定义资源定义 (CRD)。它旨在通过提供高性能、高抽象接口来解决生产模型服务用例,这些接口适用于常见的机器学习框架。它封装了自动缩放、网络、健康检查和服务器配置的复杂性,将前沿的服务功能如 GPU 自动缩放、零扩展和金丝雀发布带到机器学习部署中。图 8.5 是一个说明 KServe 在生态系统中的位置的图。

图 8.5 KServe 在生态系统中的定位
Kubeflow 提供了网页用户界面。图 8.6 展示了该界面的截图。用户可以通过左侧每个标签页访问模型、流水线、实验、工件等,以促进端到端模型机器生命周期迭代的便捷性。

图 8.6 Kubeflow UI 的截图
网页用户界面与 Jupyter 笔记本集成,便于访问。还有不同语言的 SDK,以帮助用户与任何内部系统集成。此外,由于所有这些都是原生 Kubernetes 自定义资源和控制器,用户可以通过 kubectl 与所有 Kubeflow 组件交互。训练操作员 (github.com/kubeflow/training-operator) 提供了 Kubernetes 自定义资源,使得在 Kubernetes 上运行分布式或非分布式 TensorFlow、PyTorch、Apache MXNet、XGBoost 或 MPI 作业变得容易。
Kubeflow 项目已积累超过 500 位贡献者和 20,000 个 GitHub 星标。它在各种公司中得到广泛采用,拥有超过 10 个供应商,包括 Amazon AWS、Azure、Google Cloud、IBM 等。七个工作组独立维护不同的子项目。我们将使用训练操作员提交分布式模型训练作业,并使用 KServe 构建我们的模型服务组件。一旦你完成下一章,我建议在需要时尝试 Kubeflow 生态系统中的其他子项目。例如,如果你想调整模型的性能,你可以使用 Katib 的自动化机器学习和超参数调整功能。
8.3.1 基础知识
接下来,我们将更详细地了解 Kubeflow 的分布式训练操作员,并提交一个在上一节创建的 Kubernetes 本地集群中本地运行的分布式模型训练作业。让我们首先创建并激活一个专用的 kubeflow 命名空间用于我们的示例,并重用我们之前创建的现有集群。
列表 8.31 创建并切换到新的命名空间
> kubectl create ns kubeflow
> kns kubeflow
然后,我们必须回到我们的项目文件夹,应用所有清单来安装我们需要的所有工具。
列表 8.32 应用所有清单并安装所有工具
> cd code/project
> kubectl kustomize manifests | k apply -f -
注意,我们已将所有必要的工具打包在这个清单文件夹中:
-
我们将在本章中使用 Kubeflow 训练操作员进行分布式模型训练。
-
Argo Workflows (
github.com/argoproj/argo-workflows),我们将在第九章讨论工作流编排时提到,并将所有组件在一个机器学习管道中串联起来。现在我们可以忽略 Argo Workflows。
如前所述,Kubeflow 训练操作员提供了 Kubernetes 自定义资源,这使得在 Kubernetes 上运行分布式或非分布式作业变得容易,包括 TensorFlow、PyTorch、Apache MXNet、XGBoost、MPI 作业等。
在我们深入探讨 Kubeflow 之前,我们需要了解什么是自定义资源。自定义资源是 Kubernetes API 的扩展,不一定在默认的 Kubernetes 安装中可用。它是特定 Kubernetes 安装的定制化。然而,现在许多核心 Kubernetes 功能都是使用自定义资源构建的,这使得 Kubernetes 更加模块化(mng.bz/lWw2)。
自定义资源可以通过动态注册在运行中的集群中出现和消失,集群管理员可以独立于集群更新自定义资源。一旦安装了自定义资源,用户就可以使用 kubectl 创建和访问其对象,就像它们对内置资源(如 Pods)所做的那样。例如,以下列表定义了 TFJob 自定义资源,它允许我们实例化和提交一个分布式 TensorFlow 训练作业到 Kubernetes 集群。
列表 8.33 TFJob CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.4.1
name: tfjobs.kubeflow.org
spec:
group: kubeflow.org
names:
kind: TFJob
listKind: TFJobList
plural: tfjobs
singular: tfjob
所有实例化的 TFJob 自定义资源对象(tfjobs)将由训练操作员处理。以下列表提供了运行状态控制器以持续监控和处理任何提交的 tfjobs 的训练操作员的部署定义。
列表 8.34 训练操作员部署
apiVersion: apps/v1
kind: Deployment
metadata:
name: training-operator
labels:
control-plane: kubeflow-training-operator
spec:
selector:
matchLabels:
control-plane: kubeflow-training-operator
replicas: 1
template:
metadata:
labels:
control-plane: kubeflow-training-operator
annotations:
sidecar.istio.io/inject: "false"
spec:
containers:
- command:
- /manager
image: kubeflow/training-operator
name: training-operator
env:
- name: MY_POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: MY_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
securityContext:
allowPrivilegeEscalation: false
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
resources:
limits:
cpu: 100m
memory: 30Mi
requests:
cpu: 100m
memory: 20Mi
serviceAccountName: training-operator
terminationGracePeriodSeconds: 10
通过这种抽象,数据科学团队能够专注于编写将作为 TFJob 规范一部分使用的 TensorFlow Python 代码,而无需自己管理基础设施。目前,我们可以跳过底层细节,使用 TFJob 来实现我们的分布式模型训练。接下来,让我们在名为 tfjob.yaml 的文件中定义我们的 TFJob。
列表 8.35 TFJob 定义示例
apiVersion: kubeflow.org/v1
kind: TFJob
metadata:
namespace: kubeflow
generateName: distributed-tfjob-
spec:
tfReplicaSpecs:
Worker:
replicas: 2
restartPolicy: OnFailure
template:
spec:
containers:
- name: tensorflow
image: gcr.io/kubeflow-ci/tf-mnist-with-summaries:1.0
command:
- "python"
- "/var/tf_mnist/mnist_with_summaries.py"
- "--log_dir=/train/metrics"
- "--learning_rate=0.01"
- "--batch_size=100"
在这个规范中,我们要求控制器提交一个具有两个工作副本的分布式 TensorFlow 模型训练模型,每个工作副本遵循相同的容器定义,运行 MNIST 图像分类示例。
一旦定义,我们就可以通过以下列表将其提交到我们的本地 Kubernetes 集群。
列表 8.36 提交 TFJob
> kubectl create -f basics/tfjob.yaml
tfjob.kubeflow.org/distributed-tfjob-qc8fh created
我们可以通过获取 TFJob 列表来查看 TFJob 是否已成功提交。
列表 8.37 获取 TFJob 列表
> kubectl get tfjob
NAME AGE
Distributed-tfjob-qc8fh 1s
当我们获取 Pod 列表时,我们可以看到已经创建了两个工作 Pod,分别是 distributed-tfjob-qc8fh-worker-1 和 distributed-tfjob-qc8fh-worker-0,并且已经开始运行。其他 Pod 可以忽略,因为它们是运行 Kubeflow 和 Argo Workflow 操作员的 Pod。
列表 8.38 获取 Pod 列表
> kubectl get pods
NAME READY STATUS RESTARTS AGE
workflow-controller-594494ffbd-2dpkj 1/1 Running 0 21m
training-operator-575698dc89-mzvwb 1/1 Running 0 21m
argo-server-68c46c5c47-vfh82 1/1 Running 0 21m
distributed-tfjob-qc8fh-worker-1 1/1 Running 0 10s
distributed-tfjob-qc8fh-worker-0 1/1 Running 0 12s
机器学习系统由许多不同的组件组成。我们只使用了 Kubeflow 来提交分布式模型训练作业,但尚未与其他组件连接。在下一节中,我们将探讨 Argo Workflows 的基本功能,以便将单个工作流中的不同步骤连接起来,以便它们可以按特定顺序执行。
8.3.2 练习
- 如果您的模型训练需要参数服务器,您能否在 TFJob 中表达这一点?
8.4 Argo Workflows:容器原生工作流引擎
Argo 项目是一套开源工具,用于在 Kubernetes 上部署和运行应用程序和工作负载。它扩展了 Kubernetes API,并为应用程序部署、容器编排、事件自动化、渐进式交付等解锁了新的强大功能。它包括四个核心项目:Argo CD、Argo Rollouts、Argo Events 和 Argo Workflows。除了这些核心项目之外,许多其他生态系统项目都是基于、扩展或与 Argo 兼容的。有关 Argo 的完整资源列表可以在github.com/terrytangyuan/awesome-argo找到。
Argo CD 是一个用于 Kubernetes 的声明式 GitOps 应用程序交付工具。它在 Git 中以声明方式管理应用程序定义、配置和环境。Argo CD 的用户体验使得 Kubernetes 应用程序部署和生命周期管理自动化、可审计且易于理解。它附带了一个 UI,工程师可以通过它查看其集群中的情况,并监视应用程序部署等。图 8.7 是 Argo CD UI 中资源树的截图。

图 8.7 Argo CD UI 中的资源树截图
Argo Rollouts 是一个 Kubernetes 控制器和一组 CRDs,它提供了渐进式部署功能。它引入了蓝绿和金丝雀部署、金丝雀分析、实验和渐进式交付功能到您的 Kubernetes 集群中。
接下来是 Argo Events。它是一个基于事件的 Kubernetes 依赖管理器。它可以定义来自各种事件源(如 webhooks、Amazon S3、计划和时间流)的多个依赖关系,并在成功解决事件依赖关系后触发 Kubernetes 对象。可用的完整事件源列表可以在图 8.8 中找到。

图 8.8 Argo Events 中可用的事件源
最后,Argo Workflows 是一个用于编排并行作业的容器原生工作流引擎,作为 Kubernetes CRD 实现。用户可以定义工作流,其中每个步骤都是一个独立的容器,将多步骤工作流建模为任务序列,或使用图来捕获任务之间的依赖关系,并运行用于机器学习或数据处理的高计算量作业。用户通常将 Argo Workflows 与 Argo Events 一起使用,以触发基于事件的 workflows。Argo Workflows 的主要用例包括机器学习管道、数据处理、ETL(提取、转换、加载)、基础设施自动化、持续交付和集成。
Argo Workflows 还提供了命令行界面(CLI)、服务器、UI 和不同语言的 SDK 等接口。CLI 用于通过命令行管理工作流和执行提交、暂停和删除工作流等操作。服务器用于与其他服务集成。存在 REST 和 gRPC 服务接口。UI 用于管理和可视化工作流以及工作流创建的任何工件/日志,以及其他有用的信息,例如资源使用分析。我们将通过一些 Argo Workflows 的示例来准备我们的项目。
8.4.1 基础知识
在我们查看一些示例之前,让我们确保我们手头有 Argo Workflows UI。这是可选的,因为您仍然可以通过命令行直接使用 kubectl 与 Kubernetes 交互来在这些示例中成功,但看到 UI 中的有向无环图 (DAG) 可视化以及访问其他功能也很不错。默认情况下,Argo Workflows UI 服务未公开到外部 IP。要访问 UI,请使用以下列表中的方法。
列表 8.39 Argo 服务器端口转发
> kubectl port-forward svc/argo-server 2746:2746
接下来,访问以下 URL 以访问 UI:https://localhost:2746。或者,您可以将负载均衡器公开以获取外部 IP,以便在本地集群中访问 Argo Workflows UI。有关更多详细信息,请参阅官方文档:argoproj.github.io/argo-workflows/argo-server/。图 8.9 是 Argo Workflows UI 的截图,展示了类似 map-reduce 的工作流程。

图 8.9 展示类似 map-reduce 风格工作流程的 Argo Workflows UI
以下列表是 Argo Workflows 的基本“hello world”示例。我们可以指定此工作流程的容器镜像和要运行的命令,并打印出“hello world”消息。
列表 8.40 “Hello world” 示例
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: hello-world-
spec:
entrypoint: whalesay
serviceAccountName: argo
templates:
- name: whalesay
container:
image: docker/whalesay
command: [cowsay]
args: ["hello world"]
让我们继续将工作流程提交到我们的集群。
列表 8.41 提交工作流程
> kubectl create -f basics/argo-hello-world.yaml
workflow.argoproj.io/hello-world-zns4g created
然后,我们可以检查它是否已成功提交并开始运行。
列表 8.42 获取工作流程列表
> kubectl get wf
NAME STATUS AGE
hello-world-zns4g Running 2s
一旦工作流程状态变为成功,我们就可以检查由工作流程创建的 Pod 的状态。首先,让我们找到与工作流程相关联的所有 Pod。我们可以使用标签选择器来获取 Pod 列表。
列表 8.43 获取属于此工作流程的 Pod 列表
> kubectl get pods -l workflows.argoproj.io/workflow=hello-world-zns4g
NAME READY STATUS RESTARTS AGE
hello-world-zns4g 0/2 Completed 0 8m57s
一旦我们知道 Pod 名称,我们就可以获取该 Pod 的日志。
列表 8.44 检查 Pod 日志
> kubectl logs hello-world-zns4g -c main
_____________
< hello world >
-------------
\
\
\
## .
## ## ## ==
## ## ## ## ===
/""""""""""""""""___/ ===
~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~
\______ o __/
\ \ __/
\____\______/
如预期的那样,我们得到了与之前章节中简单的 Kubernetes Pod 相同的日志,因为此工作流程只运行了一个“hello world”步骤。
下一个示例使用资源模板,您可以在其中指定工作流程将提交给 Kubernetes 集群的 Kubernetes 自定义资源。在这里,我们创建了一个名为 cm-example 的 Kubernetes 配置映射,其中包含一个简单的键值对。配置映射是 Kubernetes 原生对象,用于存储键值对。
列表 8.45 资源模板
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: k8s-resource-
spec:
entrypoint: k8s-resource
serviceAccountName: argo
templates:
- name: k8s-resource
resource:
action: create
manifest: |
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-example
data:
some: value
此示例对 Python 用户可能最有用。您可以将 Python 脚本作为模板定义的一部分来编写。我们可以使用内置的 Python 随机模块生成一些随机数。或者,您可以在容器模板内部指定脚本的执行逻辑,而不需要编写内联 Python 代码,就像在“hello world”示例中看到的那样。
列表 8.46 脚本模板
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: script-tmpl-
spec:
entrypoint: gen-random-int
serviceAccountName: argo
templates:
- name: gen-random-int
script:
image: python:alpine3.6
command: [python]
source: |
import random
i = random.randint(1, 100)
print(i)
让我们提交它。
列表 8.47 提交脚本模板工作流程
> kubectl create -f basics/argo-script-template.yaml
workflow.argoproj.io/script-tmpl-c5lhb created
现在,让我们检查其日志以查看是否生成了一个随机数。
列表 8.48 检查 Pod 日志
> kubectl logs script-tmpl-c5lhb
25
到目前为止,我们只看到了单步工作流的示例。Argo Workflow 还允许用户通过指定每个任务的依赖关系来定义工作流为一个 DAG。对于复杂的工作流,DAG 可以更容易地维护,并且在运行任务时允许最大并行性。
让我们看看 Argo Workflows 创建的菱形 DAG 的一个示例。这个 DAG 由四个步骤(A、B、C 和 D)组成,每个步骤都有自己的依赖关系。例如,步骤 C 依赖于步骤 A,步骤 D 依赖于步骤 B 和 C。
列表 8.49 使用 DAG 的菱形示例
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: dag-diamond-
spec:
serviceAccountName: argo
entrypoint: diamond
templates:
- name: echo
inputs:
parameters:
- name: message
container:
image: alpine:3.7
command: [echo, "{{inputs.parameters.message}}"]
- name: diamond
dag:
tasks:
- name: A
template: echo
arguments:
parameters: [{name: message, value: A}]
- name: B
dependencies: [A]
template: echo
arguments:
parameters: [{name: message, value: B}]
- name: C
dependencies: [A]
template: echo
arguments:
parameters: [{name: message, value: C}]
- name: D
dependencies: [B, C]
template: echo
arguments:
parameters: [{name: message, value: D}]
让我们提交它。
列表 8.50 提交 DAG 工作流
> kubectl create -f basics/argo-dag-diamond.yaml
workflow.argoproj.io/dag-diamond-6swfg created
当工作流完成时,我们将看到每个步骤都有四个 Pod,每个步骤都会打印出其步骤名称——A、B、C 和 D。
列表 8.51 获取属于此工作流的 Pod 列表
> kubectl get pods -l workflows.argoproj.io/workflow=dag-diamond-6swfg
NAME READY STATUS RESTARTS AGE
dag-diamond-6swfg-echo-4189448097 0/2 Completed 0 76s
dag-diamond-6swfg-echo-4155892859 0/2 Completed 0 66s
dag-diamond-6swfg-echo-4139115240 0/2 Completed 0 66s
dag-diamond-6swfg-echo-4239780954 0/2 Completed 0 56s
DAG 的可视化可在 Argo Workflows UI 中查看。在 UI 中,通常更直观地看到工作流是如何以菱形流程执行,如图 8.10 所示。

图 8.10 UI 中菱形工作流的截图
接下来,我们将查看一个简单的硬币翻转示例,以展示 Argo Workflows 提供的条件语法。我们可以指定一个条件来指示我们是否要运行下一个步骤。例如,我们首先运行 flip-coin 步骤,这是我们之前看到的 Python 脚本,如果结果返回正面,我们运行名为 heads 的模板,它打印出另一个日志说它是正面。否则,我们打印出它是反面。因此,我们可以在不同步骤的 when 子句中指定这些条件。
列表 8.52 硬币翻转示例
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: coinflip-
spec:
serviceAccountName: argo
entrypoint: coinflip
templates:
- name: coinflip
steps:
- - name: flip-coin
template: flip-coin
- - name: heads
template: heads
when: "{{steps.flip-coin.outputs.result}} == heads"
- name: tails
template: tails
when: "{{steps.flip-coin.outputs.result}} == tails"
- name: flip-coin
script:
image: python:alpine3.6
command: [python]
source: |
import random
result = "heads" if random.randint(0,1) == 0 else "tails"
print(result)
- name: heads
container:
image: alpine:3.6
command: [sh, -c]
args: ["echo \"it was heads\""]
- name: tails
container:
image: alpine:3.6
command: [sh, -c]
args: ["echo \"it was tails\""]
让我们提交工作流。
列表 8.53 提交硬币翻转示例
> kubectl create -f basics/argo-coinflip.yaml
workflow.argoproj.io/coinflip-p87ff created
图 8.11 是 UI 中 flip-coin 工作流的外观截图。

图 8.11 UI 中 flip-coin 工作流的截图
当我们获取工作流列表时,我们发现只有两个 Pod。
列表 8.54 获取属于此工作流的 Pod 列表
> kubectl get pods -l workflows.argoproj.io/workflow=coinflip-p87ff
coinflip-p87ff-flip-coin-1071502578 0/2 Completed 0 23s
coinflip-p87ff-tails-2208102039 0/2 Completed 0 13s
我们可以检查 flip-coin 步骤的日志,以查看它是否打印出反面,因为接下来执行的是反面步骤:
> kubectl logs coinflip-p87ff-flip-coin-1071502578
tails
就这样!我们刚刚学习了 Argo Workflows 的基本语法,这应该涵盖了下一章的所有先决条件!在下一章中,我们将使用 Argo Workflows 来实现由第七章中介绍的真正系统组件组成的端到端机器学习工作流。
8.4.2 练习
-
除了像 {{steps.flip-coin.outputs .result}} 这样访问每个步骤的输出之外,还有哪些其他可用的变量?
-
您可以通过 Git 提交或其他事件自动触发工作流吗?
8.5 练习答案
第 8.1 节
-
是的,通过 model = tf.keras.models.load_model('my_model.h5'); modele .evaluate(x_test, y_test)
-
您应该可以通过将调谐器更改为 kt.RandomSearch (model_builder) 来轻松完成它。
第 8.2 节
-
kubectl get pod
-o json -
是的,你可以在 pod.spec.containers 中定义额外的容器,除了现有的单个容器。
第 8.3 节
- 与工作副本类似,在你的 TFJob 规范中定义 parameterServer 副本以指定参数服务器数量。
第 8.4 节
-
完整的列表在此处可用:
mng.bz/d1Do。 -
是的,你可以使用 Argo Events 来监视 Git 事件并触发工作流。
摘要
-
我们使用 TensorFlow 在单台机器上训练了 MNIST 数据集的机器学习模型。
-
我们学习了 Kubernetes 的基本概念,并通过在本地 Kubernetes 集群中实施它们来获得实践经验。
-
我们通过 Kubeflow 将分布式模型训练作业提交到 Kubernetes。
-
我们学习了不同类型的模板以及如何使用 Argo Workflows 定义 DAGs 或顺序步骤。
9 完整实现
本章涵盖
-
使用 TensorFlow 实现数据摄取组件
-
定义机器学习模型并提交分布式模型训练作业
-
实现单实例模型服务器以及复制模型服务器
-
构建我们机器学习系统的有效全流程
在本书的前一章中,我们学习了我们将用于项目的四个核心技术的基础知识:TensorFlow、Kubernetes、Kubeflow 和 Argo Workflows。我们了解到 TensorFlow 执行数据处理、模型构建和模型评估。我们还学习了 Kubernetes 的基本概念,并启动了我们的本地 Kubernetes 集群,我们将将其作为我们的核心分布式基础设施。此外,我们成功地将分布式模型训练作业提交到本地 Kubernetes 集群,使用了 Kubeflow。在上一章的结尾,我们学习了如何使用 Argo Workflows 构建和提交一个基本的“hello world”工作流和一个复杂的 DAG 结构化工作流。
在本章中,我们将使用第七章中设计的架构来实现端到端机器学习系统。我们将完全实现每个组件,这些组件将结合之前讨论的模式。我们将使用几个流行的框架和尖端技术,特别是 TensorFlow、Kubernetes、Kubeflow、Docker 和 Argo Workflows,这些我们在第八章中介绍,以在本章中构建分布式机器学习工作流的不同组件。
9.1 数据摄取
我们端到端工作流中的第一个组件是数据摄取。我们将使用第 2.2 节中介绍的 Fashion-MNIST 数据集来构建数据摄取组件。图 9.1 显示了在端到端工作流左侧深色框中的该组件。

图 9.1 全流程机器学习系统中的数据摄取组件(深色框)
回想一下,这个数据集包含一个包含 60,000 个示例的训练集和一个包含 10,000 个示例的测试集。每个示例是一个 28 × 28 的灰度图像,代表一个 Zalando 的商品图像,并关联到 10 个类别中的一个标签。此外,Fashion-MNIST 数据集被设计为作为原始 MNIST 数据集的直接替换,用于基准测试机器学习算法。它共享相同的训练和测试分割的图像大小和结构。图 9.2 是 Fashion-MNIST 中所有 10 个类别(T 恤/上衣、裤子、开衫、连衣裙、外套、凉鞋、衬衫、运动鞋、包和踝靴)的图像集合的截图,其中每个类别在截图中占据三行。

图 9.2 从 Fashion-MNIST 数据集的所有 10 个类别(T 恤/上衣、裤子、开衫、连衣裙、外套、凉鞋、衬衫、运动鞋、包和踝靴)收集的图像截图
图 9.3 是对训练集中前几个示例图像的仔细查看,以及每个图像上方的对应文本标签。

图 9.3 仔细查看训练集中前几个示例图像及其对应的文本标签
在 9.1.1 节中,我们将介绍单节点数据管道的实现,该管道用于处理 Fashion-MNIST 数据集。此外,9.1.2 节将涵盖分布式数据管道的实现,为 9.2 节中的分布式模型训练准备数据。
9.1.1 单节点数据管道
让我们先看看如何构建一个单节点数据管道,该管道在您的笔记本电脑上本地工作,而不使用本地 Kubernetes 集群。对于用 TensorFlow 编写的机器学习程序来说,通过 tf.data 模块中的方法来消费数据是最佳方式。tf.data API 允许用户轻松构建复杂的输入管道。例如,图像模型的管道可能从各种文件系统中的文件中聚合数据,对每个图像应用随机转换,并从图像中创建用于模型训练的批次。
tf.data API 允许它处理大量数据,从不同的数据格式中读取,并执行复杂的转换。它包含一个 tf.data.Dataset 抽象,表示一系列元素,其中每个元素由一个或多个组件组成。让我们用图像管道来举例说明。图像输入管道中的一个元素可能是一个单独的训练示例,由一对张量组件表示图像及其标签。
以下列表提供了代码片段,用于将 Fashion-MNIST 数据集加载到 tf.data.Dataset 对象中,并执行一些必要的预处理步骤以准备我们的模型训练:
-
将数据集的缩放范围从 (0, 255] 调整到 (0., 1.]。
-
将图像的多维数组转换为模型可以接受的 float32 类型。
-
选择训练数据,将其缓存在内存中以加快训练速度,并使用 10,000 个缓冲区大小对其进行洗牌。
列表 9.1 加载 Fashion-MNIST 数据集
import tensorflow_datasets as tfds
import tensorflow as tf
def make_datasets_unbatched():
def scale(image, label):
image = tf.cast(image, tf.float32)
image /= 255
return image, label
datasets, _ = tfds.load(name='fashion_mnist',
with_info=True, as_supervised=True)
return datasets['train'].map(scale).cache().shuffle(10000)
注意,我们导入了 tensorflow_datasets 模块。TensorFlow 数据集,它由一系列用于各种任务(如图像分类、目标检测、文档摘要等)的数据集组成,可以与 TensorFlow 和其他 Python 机器学习框架一起使用。
tf.data.Dataset 对象是一个洗牌后的数据集,其中每个元素由图像及其标签组成,其形状和数据类型信息如下列表所示。
列表 9.2 检查 tf.data 对象
>>> ds = make_datasets_unbatched()
>>> ds
<ShuffleDataset element_spec=(
TensorSpec(shape=(28, 28, 1),
dtype=tf.float32, name=None),
9.1.2 分布式数据管道
现在,让我们看看我们如何以分布式方式消耗我们的数据集。在下一节中,我们将使用 tf.distribute.MultiWorkerMirroredStrategy 进行分布式训练。假设我们已经实例化了一个策略对象。我们将通过 Python 的 with 语法在策略的作用域内实例化我们的数据集,使用与之前为单节点使用情况定义的相同函数。
我们需要调整一些配置来构建我们的分布式输入管道。首先,我们创建重复的数据批次,其中总批次大小等于每个副本的批次大小乘以聚合梯度的副本数量。这确保了我们将为每个模型训练工作者中的每个批次提供足够的记录。换句话说,同步的副本数量等于在模型训练期间参与梯度 allreduce 操作的设备数量。例如,当用户或训练代码在分布式数据迭代器上调用 next()时,每个副本都会返回一个按副本划分的数据批次大小。重新批处理的数据集基数总是副本数量的倍数。
此外,我们还想配置 tf.data 以启用自动数据分片。由于数据集在分布式范围内,在多工作者训练模式下,输入数据集将自动分片。更具体地说,每个数据集将在工作者的 CPU 设备上创建,并且当 tf.data.experimental.AutoShardPolicy 设置为 AutoShardPolicy.DATA 时,每组工作者将在整个数据集的子集上训练模型。一个好处是,在每次模型训练步骤中,每个工作者将处理全局批次大小的非重叠数据集元素。每个工作者将处理整个数据集,并丢弃不属于其自身的部分。请注意,为了正确分区数据集元素,数据集需要以确定性的顺序产生元素,这应该已经由我们使用的 TensorFlow Datasets 库保证。
列表 9.3 配置分布式数据管道
BATCH_SIZE_PER_REPLICA = 64
BATCH_SIZE = BATCH_SIZE_PER_REPLICA * strategy.num_replicas_in_sync
with strategy.scope():
ds_train = make_datasets_unbatched().batch(BATCH_SIZE).repeat()
options = tf.data.Options()
options.experimental_distribute.auto_shard_policy = \
tf.data.experimental.AutoShardPolicy.DATA
ds_train = ds_train.with_options(options)
model = build_and_compile_model()
9.2 模型训练
我们讨论了本地节点和分布式数据管道的数据摄取组件的实施,以及我们如何在不同工作者之间正确地分片数据集,以便它能够与分布式模型训练一起工作。在本节中,让我们深入了解模型训练组件的实施细节。模型训练组件的架构图可以在图 9.4 中找到。

图 9.4 整体架构中模型训练组件的示意图。在三个不同的模型训练步骤之后,有一个模型选择步骤。这些模型训练步骤将训练三个不同的模型——即 CNN、带有 dropout 的 CNN 和带有批归一化的 CNN——它们相互竞争以获得更好的统计性能。
我们将在第 9.2.1 节中学习如何使用 TensorFlow 定义这三个模型,并在第 9.2.2 节中执行分布式模型训练作业。在第 9.2.3 节中,我们将实现模型选择步骤,该步骤选择将用于我们端到端机器学习工作流程中模型服务组件的顶级模型。
9.2.1 模型定义和单节点训练
接下来,我们将查看 TensorFlow 代码来定义和初始化第一个模型,这是一个卷积神经网络(CNN)模型,我们在前面的章节中介绍过,具有三个卷积层。我们使用 Sequential()初始化模型,这意味着我们将按顺序添加层。第一层是输入层,我们指定了之前定义的输入管道的形状。请注意,我们还明确地为输入层命名,以便我们可以在推理输入中传递正确的键,我们将在第 9.3 节中更深入地讨论这一点。
在添加输入层、三个卷积层、随后是最大池化层和密集层之后,我们将打印出模型架构的摘要,并使用 Adam 作为其优化器、准确率作为我们用于评估模型的指标,以及稀疏分类交叉熵作为损失函数来编译模型。
列表 9.4 定义基本 CNN 模型
def build_and_compile_cnn_model():
print("Training CNN model")
model = models.Sequential()
model.add(layers.Input(shape=(28, 28, 1), name='image_bytes'))
model.add(
layers.Conv2D(32, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.Flatten())
model.add(layers.Dense(64, activation='relu'))
model.add(layers.Dense(10, activation='softmax'))
model.summary()
model.compile(optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
return model
我们已经成功定义了我们的基本 CNN 模型。接下来,我们基于 CNN 模型定义了两个模型。一个模型添加了一个批量归一化层,以强制特定层的每个神经元(激活)的预激活具有零均值和单位标准差。另一个模型有一个额外的 dropout 层,其中一半的隐藏单元将被随机丢弃,以减少模型的复杂性和加快计算速度。其余的代码与基本 CNN 模型相同。
列表 9.5 定义基本 CNN 模型的变体
def build_and_compile_cnn_model_with_batch_norm():
print("Training CNN model with batch normalization")
model = models.Sequential()
model.add(layers.Input(shape=(28, 28, 1), name='image_bytes'))
model.add(
layers.Conv2D(32, (3, 3), activation='relu'))
model.add(layers.BatchNormalization())
model.add(layers.Activation('sigmoid'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.BatchNormalization())
model.add(layers.Activation('sigmoid'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.Flatten())
model.add(layers.Dense(64, activation='relu'))
model.add(layers.Dense(10, activation='softmax'))
model.summary()
model.compile(optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
return model
def build_and_compile_cnn_model_with_dropout():
print("Training CNN model with dropout")
model = models.Sequential()
model.add(layers.Input(shape=(28, 28, 1), name='image_bytes'))
model.add(
layers.Conv2D(32, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Dropout(0.5))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.Flatten())
model.add(layers.Dense(64, activation='relu'))
model.add(layers.Dense(10, activation='softmax'))
model.summary()
model.compile(optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
一旦定义了模型,我们就可以在我们的笔记本电脑上本地训练它们。让我们以基本的 CNN 模型为例。我们将创建四个将在模型训练期间执行的回调:
-
PrintLR—在每个 epoch 结束时打印学习率的回调
-
TensorBoard—回调以启动交互式 TensorBoard 可视化,以监控训练进度和模型架构
-
ModelCheckpoint—回调以保存模型权重,以便稍后进行模型推理
-
LearningRateScheduler—在每个 epoch 结束时衰减学习率的回调
一旦定义了这些回调,我们将将其传递给 fit()方法进行训练。fit()方法使用指定的 epoch 数和每个 epoch 的步数来训练模型。请注意,这里的数字仅用于演示目的,以加快我们的本地实验,可能不足以在实际应用中生成高质量的模型。
列表 9.6 使用回调进行模型训练
single_worker_model = build_and_compile_cnn_model()
checkpoint_prefix = os.path.join(args.checkpoint_dir, "ckpt_{epoch}")
class PrintLR(tf.keras.callbacks.Callback):
def on_epoch_end(self, epoch, logs=None):
print('\nLearning rate for epoch {} is {}'.format(
epoch + 1, multi_worker_model.optimizer.lr.numpy()))
callbacks = [
tf.keras.callbacks.TensorBoard(log_dir='./logs'),
tf.keras.callbacks.ModelCheckpoint(filepath=checkpoint_prefix,
save_weights_only=True),
tf.keras.callbacks.LearningRateScheduler(decay),
PrintLR()
]
single_worker_model.fit(ds_train,
epochs=1,
steps_per_epoch=70,
callbacks=callbacks)
我们将在日志中看到以下模型训练进度:
Learning rate for epoch 1 is 0.0010000000474974513
70/70 [========] - 16s 136ms/step - loss: 1.2853
- accuracy: 0.5382 - lr: 0.0010
Here’s the summary of the model architecture in the logs:
Model: "sequential"
________________________________________________________________
Layer (type) Output Shape Param #
==================================================
conv2d (Conv2D) (None, 26, 26, 32) 320
max_pooling2d (MaxPooling2D) (None, 13, 13, 32) 0
conv2d_1 (Conv2D) (None, 11, 11, 64) 18496
max_pooling2d_1 (MaxPooling2D) (None, 5, 5, 64) 0
conv2d_2 (Conv2D) (None, 3, 3, 64) 36928
flatten (Flatten) (None, 576) 0
dense (Dense) (None, 64) 36928
dense_1 (Dense) (None, 10) 650
==================================================
Total params: 93,322
Trainable params: 93,322
Non-trainable params: 0
根据这个总结,在过程中将训练 93,000 个参数。每个层的形状和参数数量也可以在总结中找到。
9.2.2 分布式模型训练
现在我们已经定义了模型并且可以在单台机器上本地训练它们,下一步是在代码中插入分布式训练逻辑,以便我们可以使用书中介绍的全局通信模式运行具有多个工作器的模型训练。我们将使用包含 MultiWorkerMirroredStrategy 的 tf.distribute 模块。这是一个在多个工作器上进行同步训练的分布式策略。它在所有工作器的每个设备上创建了模型层中所有变量的副本。此策略使用分布式集体实现(例如,all-reduce),因此多个工作器可以一起工作以加快训练速度。如果您没有适当的 GPU,您可以将 communication_options 替换为其他实现。由于我们希望确保分布式训练可以在可能没有 GPU 的不同机器上运行,我们将将其替换为 CollectiveCommunication.AUTO,这样它将自动选择任何可用的硬件。
一旦我们定义了我们的分布式训练策略,我们将在策略作用域内启动我们的分布式输入数据管道(如前所述,在 9.1.2 节中),以及策略作用域内的模型。请注意,在策略作用域内定义模型是必需的,因为 TensorFlow 根据策略能够适当地在每个工作器中复制模型层的变量。在这里,我们根据传递给这个 Python 脚本的命令行参数定义不同的模型类型(基本 CNN、带有 dropout 的 CNN 和带有批归一化的 CNN)。
我们很快就会接触到其余的标志。一旦数据管道和模型在作用域内定义,我们就可以使用 fit() 函数在分布式策略作用域之外训练模型。
列表 9.7 分布式模型训练逻辑
strategy = tf.distribute.MultiWorkerMirroredStrategy(
communication_options=tf.distribute.experimental.CommunicationOptions(
implementation=tf.distribute.experimental.CollectiveCommunication.AUTO))
BATCH_SIZE_PER_REPLICA = 64
BATCH_SIZE = BATCH_SIZE_PER_REPLICA * strategy.num_replicas_in_sync
with strategy.scope():
ds_train = make_datasets_unbatched().batch(BATCH_SIZE).repeat()
options = tf.data.Options()
options.experimental_distribute.auto_shard_policy = \
tf.data.experimental.AutoShardPolicy.DATA
ds_train = ds_train.with_options(options)
if args.model_type == "cnn":
multi_worker_model = build_and_compile_cnn_model()
elif args.model_type == "dropout":
multi_worker_model = build_and_compile_cnn_model_with_dropout()
elif args.model_type == "batch_norm":
multi_worker_model = build_and_compile_cnn_model_with_batch_norm()
else:
raise Exception("Unsupported model type: %s" % args.model_type)
multi_worker_model.fit(ds_train,
epochs=1,
steps_per_epoch=70)
一旦通过 fit() 函数完成模型训练,我们希望保存模型。用户可能会犯的一个常见错误是在所有工作器上保存模型,这可能会导致无法正确保存完成的模型,并浪费计算资源和存储空间。修复此问题的正确方法是只保存主工作器上的模型。我们可以检查环境变量 TF_CONFIG,它包含集群信息,例如任务类型和索引,以查看工作器是否为主工作器。此外,我们希望将模型保存到工作器之间的唯一路径,以避免意外错误。
列表 9.8 使用主工作器保存模型
def is_chief():
return TASK_INDEX == 0
tf_config = json.loads(os.environ.get('TF_CONFIG') or '{}')
TASK_INDEX = tf_config['task']['index']
if is_chief():
model_path = args.saved_model_dir
else:
model_path = args.saved_model_dir + '/worker_tmp_' + str(TASK_INDEX)
multi_worker_model.save(model_path)
到目前为止,我们已经看到了两个命令行标志——即 saved_model_dir 和 model_type。列表 9.9 提供了解析这些命令行参数的其余主要函数。除了这两个参数之外,还有一个 checkpoint_dir 参数,我们将使用它将我们的模型保存到 TensorFlow SavedModel 格式,这种格式可以很容易地被我们的模型服务组件消费。我们将在第 9.3 节中详细讨论这一点。我们还禁用了 TensorFlow Datasets 模块的进度条,以减少我们将看到的日志。
列表 9.9 入口点 main 函数
if __name__ == '__main__':
tfds.disable_progress_bar()
parser = argparse.ArgumentParser()
parser.add_argument('--saved_model_dir',
type=str,
required=True,
help='Tensorflow export directory.')
parser.add_argument('--checkpoint_dir',
type=str,
required=True,
help='Tensorflow checkpoint directory.')
parser.add_argument('--model_type',
type=str,
required=True,
help='Type of model to train.')
parsed_args = parser.parse_args()
main(parsed_args)
我们刚刚完成了包含分布式模型训练逻辑的 Python 脚本的编写。让我们将其容器化并构建用于在我们本地 Kubernetes 集群中运行分布式训练的镜像。在我们的 Dockerfile 中,我们将使用 Python 3.9 基础镜像,通过 pip 安装 TensorFlow 和 TensorFlow Datasets 模块,并复制我们的多工作节点分布式训练 Python 脚本。
列表 9.10 容器化
FROM python:3.9
RUN pip install tensorflow==2.11.0 tensorflow_datasets==4.7.0
COPY multi-worker-distributed-training.py /
然后,我们从刚才定义的 Dockerfile 构建镜像。由于我们的集群无法访问我们的本地镜像仓库,我们还需要将镜像导入到 k3d 集群中。然后,我们将当前命名空间设置为“kubeflow”。请阅读第八章并按照说明安装我们为此项目所需的组件。
列表 9.11 构建和导入 docker 镜像
> docker build -f Dockerfile -t kubeflow/multi-worker-strategy:v0.1 .
> k3d image import kubeflow/multi-worker-strategy:v0.1 --cluster distml
> kubectl config set-context --current --namespace=kubeflow
一旦完成工作 Pod,Pod 中的所有文件都将被回收。由于我们在 Kubernetes Pods 中的多个工作节点上运行分布式模型训练,所有模型检查点都将丢失,我们没有用于模型服务的训练模型。为了解决这个问题,我们将使用持久卷(PV)和持久卷声明(PVC)。
PV 是在集群中由管理员或动态配置的存储。它就像节点是集群资源一样,是集群中的资源。PV 是类似于卷的卷插件,但它们的生命周期独立于使用 PV 的任何单个 Pod。换句话说,PV 将在 Pod 完成或删除后继续存在和存活。
PVC 是用户对存储的请求。它与 Pod 类似。Pod 消耗节点资源,PVC 消耗 PV 资源。Pod 可以请求特定的资源级别(CPU 和内存)。声明可以请求特定的尺寸和访问模式(例如,它们可以是 ReadWriteOnce、ReadOnlyMany 或 ReadWriteMany)。
让我们创建一个 PVC,提交一个请求用于在我们的工作 Pod 中存储训练模型。在这里,我们只请求 1 Gi 的存储空间,使用 ReadWriteOnce 访问模式。
列表 9.12 持久卷声明
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: strategy-volume
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
接下来,我们将创建 PVC。
列表 9.13 创建 PVC
> kubectl create -f multi-worker-pvc.yaml
接下来,让我们定义第七章中介绍的 TFJob 规范,使用我们刚刚构建的包含分布式训练脚本的镜像。我们将必要的命令参数传递到容器中,以训练基本的 CNN 模型。Worker 规范中的 volumes 字段指定了我们刚刚创建的持久卷声明的名称,而 containers 规范中的 volumeMounts 字段指定了将文件从卷挂载到容器的文件夹。模型将保存在卷内的/trained_model 文件夹中。
列表 9.14 分布式模型训练作业定义
apiVersion: kubeflow.org/v1
kind: TFJob
metadata:
name: multi-worker-training
spec:
runPolicy:
cleanPodPolicy: None
tfReplicaSpecs:
Worker:
replicas: 2
restartPolicy: Never
template:
spec:
containers:
- name: tensorflow
image: kubeflow/multi-worker-strategy:v0.1
imagePullPolicy: IfNotPresent
command: ["python",
"/multi-worker-distributed-training.py",
"--saved_model_dir",
"/trained_model/saved_model_versions/2/",
"--checkpoint_dir",
"/trained_model/checkpoint",
"--model_type", "cnn"]
volumeMounts:
- mountPath: /trained_model
name: training
resources:
limits:
cpu: 500m
volumes:
- name: training
persistentVolumeClaim:
claimName: strategy-volume
然后,我们可以将这个 TFJob 提交到我们的集群以启动分布式模型训练。
列表 9.15 提交 TFJob
> kubectl create -f multi-worker-tfjob.yaml
一旦 Worker Pods 完成,我们会从 Pod 中注意到以下日志,表明我们以分布式方式训练了模型,并且工作者之间成功进行了通信:
Started server with target:
grpc://multi-worker-training-worker-0.kubeflow.svc:2222
/job:worker/replica:0/task:1 has connected to coordination service.
/job:worker/replica:0/task:0 has connected to coordination service.
9.2.3 模型选择
到目前为止,我们已经实现了我们的分布式模型训练组件。我们最终将训练三个不同的模型,正如 9.2.1 节中提到的,然后选择最佳模型用于模型服务。假设我们已经通过提交三个不同类型的 TFJobs 成功训练了这些模型。
接下来,我们编写 Python 代码来加载测试数据和训练好的模型,然后评估它们的性能。我们将通过 keras.models.load_model()函数从不同的文件夹中加载每个训练好的模型,并执行 model.evaluate(),它返回损失和准确率。一旦我们找到准确率最高的模型,我们就可以将其复制到不同文件夹中的新版本——即 4 号文件夹——这将被我们的模型服务组件使用。
列表 9.16 模型评估
import numpy as np
import tensorflow as tf
from tensorflow import keras
import tensorflow_datasets as tfds
import shutil
import os
def scale(image, label):
image = tf.cast(image, tf.float32)
image /= 255
return image, label
best_model_path = ""
best_accuracy = 0
for i in range(1, 4):
model_path = "trained_model/saved_model_versions/" + str(i)
model = keras.models.load_model(model_path)
datasets, _ = tfds.load(
name='fashion_mnist', with_info=True, as_supervised=True)
ds = datasets['test'].map(scale).cache().shuffle(10000).batch(64)
_, accuracy = model.evaluate(ds)
if accuracy > best_accuracy:
best_accuracy = accuracy
best_model_path = model_path
destination = "trained_model/saved_model_versions/4"
if os.path.exists(destination):
shutil.rmtree(destination)
shutil.copytree(best_model_path, destination)
print("Best model with accuracy %f is copied to %s" % (
best_accuracy, destination))
注意,在 trained_model/saved_model_versions 文件夹中的最新版本,即 4 号,将被我们的服务组件选中。我们将在下一节中讨论这一点。
然后,我们将这个 Python 脚本添加到我们的 Dockerfile 中,重新构建容器镜像,并创建一个运行模型选择组件的 Pod。以下是为配置模型选择 Pod 的 YAML 文件。
列表 9.17 模型选择 Pod 定义
apiVersion: v1
kind: Pod
metadata:
name: model-selection
spec:
containers:
- name: predict
image: kubeflow/multi-worker-strategy:v0.1
command: ["python", "/model-selection.py"]
volumeMounts:
- name: model
mountPath: /trained_model
volumes:
- name: model
persistentVolumeClaim:
claimName: strategy-volume
在检查日志时,我们看到第三个模型的准确率最高,因此我们将它复制到新版本,用于模型服务组件:
157/157 [======] - 1s 5ms/step - loss: 0.7520 - accuracy: 0.7155
157/157 [======] - 1s 5ms/step - loss: 0.7568 - accuracy: 0.7267
157/157 [======] - 1s 5ms/step - loss: 0.7683 - accuracy: 0.7282
9.3 模型服务
现在我们已经实现了分布式模型训练以及训练模型之间的模型选择。接下来,我们将实现模型服务组件。模型服务组件对于最终用户体验至关重要,因为结果将直接展示给我们的用户,如果性能不足,我们的用户会立即知道。图 9.5 显示了整体架构中的模型训练组件。

图 9.5 端到端机器学习系统中的模型服务组件(深色框)
在图 9.5 中,模型服务组件显示为模型选择和结果聚合步骤之间的两个深色框。让我们首先在 9.3.1 节中实现我们的单服务器模型推理组件,然后在 9.3.2 节中使其更具可扩展性和性能。
9.3.1 单服务器模型推理
模型推理的 Python 代码与模型评估代码非常相似。唯一的区别是在加载训练模型后,我们使用 model.predict() 方法而不是 evaluate()。这是一种测试训练模型是否能够按预期进行预测的极好方式。
列表 9.18 模型预测
import numpy as np
import tensorflow as tf
from tensorflow import keras
import tensorflow_datasets as tfds
model = keras.models.load_model("trained_model/saved_model_versions")
def scale(image, label):
image = tf.cast(image, tf.float32)
image /= 255
return image, label
datasets, _ = tfds.load(
name='fashion_mnist', with_info=True, as_supervised=True)
ds = datasets['test'].map(scale).cache().shuffle(10000).batch(64)
model.predict(ds)
或者,一旦安装完成,你可以在以下列表中像本地启动 TensorFlow Serving (github.com/tensorflow/serving) 服务器。
列表 9.19 TensorFlow Serving 命令
tensorflow_model_server --model_name=flower-sample \
--port=9000 \
--rest_api_port=8080 \
--model_base_path=trained_model/saved_model \
--rest_api_timeout_in_ms=60000
这看起来很简单,如果我们在本地进行实验,效果很好。然而,还有更多高效的方式来构建我们的模型服务组件,这将为我们通往运行分布式模型服务铺平道路,该服务结合了我们之前章节中介绍的复制模型服务器模式。
在我们深入探讨更好的解决方案之前,让我们确保我们的训练模型可以与我们的预测输入一起工作,这些输入将是一个包含键 "instances" 和 "image_bytes" 的 JSON 结构化图像字节列表,如下所示:
{
"instances":[
{
"image_bytes":{
"b64":"/9j/4AAQSkZJRgABAQAAAQABAAD
...
<truncated>
/hWY4+UVEhkoIYUx0psR+apm6VBRUZcUYFSuKZgUAf//Z"
}
}
]
}
现在是修改我们的分布式模型训练代码的时候了,以确保模型具有与提供的输入兼容的正确服务签名。我们定义的预处理函数执行以下操作:
-
从字节解码图像
-
将图像调整大小到 28 × 28,以与我们的模型架构兼容
-
将图像转换为 tf.uint8
-
定义输入签名,类型为字符串,键为 image_bytes
一旦预处理函数被定义,我们可以通过 tf.TensorSpec() 定义服务签名,然后将其传递给 tf.saved_model.save() 方法以保存与我们的输入格式兼容的模型,并在 TensorFlow Serving 进行推理调用之前对其进行预处理。
列表 9.20 模型服务签名定义
def _preprocess(bytes_inputs):
decoded = tf.io.decode_jpeg(bytes_inputs, channels=1)
resized = tf.image.resize(decoded, size=(28, 28))
return tf.cast(resized, dtype=tf.uint8)
def _get_serve_image_fn(model):
@tf.function(
input_signature=[tf.TensorSpec([None],
dtype=tf.string, name='image_bytes')])
def serve_image_fn(bytes_inputs):
decoded_images = tf.map_fn(_preprocess, bytes_inputs, dtype=tf.uint8)
return model(decoded_images)
return serve_image_fn
signatures = {
"serving_default": _get_serve_image_fn(multi_worker_model).get_concrete_function(
tf.TensorSpec(shape=[None], dtype=tf.string, name='image_bytes')
)
}
tf.saved_model.save(multi_worker_model, model_path, signatures=signatures)
一旦修改了分布式模型训练脚本,我们可以重新构建我们的容器镜像,并按照 9.2.2 节中的说明从头开始重新训练我们的模型。
接下来,我们将使用 KServe,正如我们在技术概述中提到的,来创建一个推理服务。列表 9.21 提供了定义 KServe 推理服务的 YAML。我们需要指定模型格式,以便 KServe 知道用于服务的模型(例如,TensorFlow Serving)。此外,我们需要提供训练模型的 URI。在这种情况下,我们可以指定 PVC 名称和训练模型的路径,格式为 pvc://
列表 9.21 推理服务定义
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: flower-sample
spec:
predictor:
model:
modelFormat:
name: tensorflow
storageUri: "pvc://strategy-volume/saved_model_versions"
让我们安装 KServe 并创建我们的推理服务!
列表 9.22 安装 KServe 和创建推理服务
> curl -s "https:/ /raw.githubusercontent.com/
kserve/kserve/v0.10.0-rc1/hack/quick_install.sh" | bash
> kubectl create -f inference-service.yaml
我们可以检查其状态以确保它已准备好提供服务。
列表 9.23 获取推理服务的详细信息
> kubectl get isvc
NAME URL READY AGE
flower-sample <truncated...example.com> True 25s
一旦服务创建成功,我们将它端口转发到本地,以便我们可以在本地向其发送请求。
列表 9.24 端口转发推理服务
> INGRESS_GATEWAY_SERVICE=$(kubectl get svc --namespace \
istio-system --selector="app=istio-ingressgateway" --output \ jsonpath='{.items[0].metadata.name}')
> kubectl port-forward --namespace istio-system svc/${INGRESS_GATEWAY_SERVICE} 8080:80
如果端口转发成功,你应该能看到以下内容:
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080
让我们打开另一个终端并执行以下 Python 脚本来向我们的模型服务服务发送一个示例推理请求并打印出响应文本。
列表 9.25 使用 Python 发送推理请求
import requests
import json
input_path = "inference-input.json"
with open(input_path) as json_file:
data = json.load(json_file)
r = requests.post(
url="http:/ /localhost:8080/v1/models/flower-sample:predict",
data=json.dumps(data),
headers={'Host': 'flower-sample.kubeflow.example.com'})
print(r.text)
我们 KServe 模型服务服务的响应如下,它包括 Fashion-MNIST 数据集中每个类别的预测概率:
{
"predictions": [[0.0, 0.0, 1.22209595e-11,
0.0, 1.0, 0.0, 7.07406329e-32, 0.0, 0.0, 0.0]]
}
或者,我们可以使用 curl 发送请求。
列表 9.26 使用 curl 发送推理请求
# Start another terminal
export INGRESS_HOST=localhost
export INGRESS_PORT=8080
MODEL_NAME=flower-sample
INPUT_PATH=@./inference-input.json
SERVICE_HOSTNAME=$(kubectl get inferenceservice \
${MODEL_NAME} -o jsonpath='{.status.url}' | \
cut -d "/" -f 3)
curl -v -H "Host: ${SERVICE_HOSTNAME}" "http:/ /${INGRESS_HOST}:${INGRESS_PORT}/v1/
models/$MODEL_NAME:predict" -d $INPUT_PATH
输出的概率应该与我们刚才看到的相同:
* Trying ::1:8080...
* Connected to localhost (::1) port 8080 (#0)
> POST /v1/models/flower-sample:predict HTTP/1.1
> Host: flower-sample.kubeflow.example.com
> User-Agent: curl/7.77.0
> Accept: */*
> Content-Length: 16178
> Content-Type: application/x-www-form-urlencoded
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-length: 102
< content-type: application/json
< date: Thu, 05 Jan 2023 21:11:36 GMT
< x-envoy-upstream-service-time: 78
< server: istio-envoy
<
{
"predictions": [[0.0, 0.0, 1.22209595e-11, 0.0,
1.0, 0.0, 7.07406329e-32, 0.0, 0.0, 0.0]
]
* Connection #0 to host localhost left intact
}
如前所述,尽管我们在 KServe InferenceService 规范中指定了包含训练模型的整个目录,但利用 TensorFlow Serving 的模型服务服务将选择该特定文件夹中的最新版本 4,这是我们第 9.2.3 节中选择的最佳模型。我们可以从服务 Pod 的日志中观察到这一点。
列表 9.27 检查模型服务器日志
> kubectl logs flower-sample-predictor-default
-00001-deployment-f67767f6c2fntx -c kserve-container
这里是日志:
Building single TensorFlow model file config:
model_name: flower-sample model_base_path: /mnt/models
Adding/updating models.
...
<truncated>
Successfully loaded servable version
{name: flower-sample version: 4}
9.3.2 复制模型服务器
在上一节中,我们在本地 Kubernetes 集群中成功部署了我们的模型服务服务。这可能对于运行本地服务实验来说是足够的,但如果它部署到生产系统中,这些系统服务于现实世界的模型服务流量,那就远远不够理想了。当前的模型服务服务是一个 Kubernetes Pod,分配的计算资源有限且预先请求。当模型服务请求的数量增加时,单实例模型服务器将无法支持工作负载,并且可能会耗尽计算资源。
为了解决这个问题,我们需要有多个模型服务器实例来处理更多的动态模型服务请求。幸运的是,KServe 可以根据每个 Pod 的平均飞行请求数量自动扩展,这使用了 Knative Serving 自动扩展器。
以下列表提供了启用自动扩展的推理服务规范。scaleTarget 字段指定了自动扩展器监视的指标类型的整数目标值。此外,scaleMetric 字段定义了自动扩展器监视的扩展指标类型。可能的指标有并发性、RPS、CPU 和内存。在这里,我们只允许每个推理服务实例处理一个并发请求。换句话说,当有更多请求时,我们将启动一个新的推理服务 Pod 来处理每个额外的请求。
列表 9.28 复制模型推理服务
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: flower-sample
spec:
predictor:
scaleTarget: 1
scaleMetric: concurrency
model:
modelFormat:
name: tensorflow
storageUri: "pvc://strategy-volume/saved_model_versions"
假设没有请求,我们应该只看到一个运行中的推理服务 Pod。接下来,让我们以 30 秒的间隔发送流量,保持五个正在进行的请求。我们使用相同的服务主机名和入口地址,以及相同的推理输入和训练模型。请注意,我们正在使用工具 hey,这是一个向 Web 应用程序发送一些负载的小程序。在执行以下命令之前,请按照github.com/rakyll/hey上的说明安装它。
列表 9.29 发送流量以测试负载
> hey -z 30s -c 5 -m POST \
-host ${SERVICE_HOSTNAME} \
-D inference-input.json "http:/ /${INGRESS_HOST}:${INGRESS_PORT}
/v1/models/$MODEL_NAME:predict"
下面的命令预期输出包括推理服务处理请求的摘要。例如,该服务处理了 230,160 字节的推理输入和每秒 95.7483 个请求。您还可以找到有用的响应时间直方图和延迟分布:
Summary:
Total: 30.0475 secs
Slowest: 0.2797 secs
Fastest: 0.0043 secs
Average: 0.0522 secs
Requests/sec: 95.7483
Total data: 230160 bytes
Size/request: 80 bytes
Response time histogram:
0.004 [1] |
0.032 [1437] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.059 [3] |
0.087 [823] |■■■■■■■■■■■■■■■■■■■■■■■
0.114 [527] |■■■■■■■■■■■■■■■
0.142 [22] |■
0.170 [5] |
0.197 [51] |■
0.225 [7] |
0.252 [0] |
0.280 [1] |
Latency distribution:
10% in 0.0089 secs
25% in 0.0123 secs
50% in 0.0337 secs
75% in 0.0848 secs
90% in 0.0966 secs
95% in 0.1053 secs
99% in 0.1835 secs
Details (average, fastest, slowest):
DNS+dialup: 0.0000 secs, 0.0043 secs, 0.2797 secs
DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0009 secs
req write: 0.0000 secs, 0.0000 secs, 0.0002 secs
resp wait: 0.0521 secs, 0.0042 secs, 0.2796 secs
resp read: 0.0000 secs, 0.0000 secs, 0.0005 secs
Status code distribution:
[200] 2877 responses
如预期,我们看到有五个正在运行的推理服务 Pod 并发处理请求,其中每个 Pod 只处理一个请求。
列表 9.30 获取模型服务器 Pod 列表
> kubectl get pods
NAME READY STATUS RESTARTS AGE
flower-<truncated>-sr5wd 3/3 Running 0 12s
flower--<truncated>-swnk5 3/3 Running 0 22s
flower--<truncated>-t2njf 3/3 Running 0 22s
flower--<truncated>-vdlp9 3/3 Running 0 22s
flower--<truncated>-vm58d 3/3 Running 0 42s
hey 命令完成后,我们只会看到一个正在运行的 Pod。
列表 9.31 再次获取模型服务器 Pod 列表
> kubectl get pods
NAME READY STATUS RESTARTS AGE
9.4 端到端工作流程
我们刚刚实现了前几节中所有组件。现在,是时候将它们组合在一起了!在本节中,我们将使用 Argo Workflows 定义一个端到端工作流程,该工作流程包括我们刚刚实现的组件。如果您对所有组件仍然不熟悉,请回到前面的章节,并刷新第八章中关于基本 Argo Workflows 的知识。
下面是对我们将要实施的端到端工作流程的回顾。图 9.6 是我们正在构建的端到端工作流程的示意图。该图为了说明目的包含了两个模型服务步骤,但我们在 Argo 工作流程中只实现一个步骤。它将根据请求流量自动扩展到更多实例,如第 9.3.2 节所述。

图 9.6 我们正在构建的端到端机器学习系统架构图
在接下来的章节中,我们将通过使用 Argo 按顺序连接步骤来定义整个工作流程,然后通过实现步骤记忆化来优化工作流程以供未来执行。
9.4.1 顺序步骤
首先,让我们看看入口模板和涉及工作流程的主要步骤。入口模板的名称是 tfjob-wf,它由以下步骤组成(为了简单起见,每个步骤使用具有相同名称的模板):
-
data-ingestion-step 包含数据摄入步骤,我们将使用它来在模型训练之前下载和预处理数据集。
-
distributed-tf-training-steps 是一个由多个子步骤组成的步骤组,其中每个子步骤代表特定模型类型的分布式模型训练步骤。
-
model-selection-step是一个步骤,用于从我们在先前步骤中训练的不同模型中选择最佳模型。 -
create-model-serving-service通过 KServe 创建模型服务。
列表 9.32 工作流入口模板
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: tfjob-wf-
namespace: kubeflow
spec:
entrypoint: tfjob-wf
podGC:
strategy: OnPodSuccess
volumes:
- name: model
persistentVolumeClaim:
claimName: strategy-volume
templates:
- name: tfjob-wf
steps:
- - name: data-ingestion-step
template: data-ingestion-step
- - name: distributed-tf-training-steps
template: distributed-tf-training-steps
- - name: model-selection-step
template: model-selection-step
- - name: create-model-serving-service
template: create-model-serving-service
注意,我们指定了 podGC 策略为 OnPodSuccess,因为我们将在有限的计算资源下创建大量 Pod,用于我们本地 k3s 集群中的不同步骤,因此,在 Pod 成功后立即删除 Pod 可以释放后续步骤的计算资源。OnPodCompletion 策略也是可用的;无论 Pod 失败还是成功,它都会在完成时删除 Pod。我们不会使用它,因为我们希望保留失败的 Pod 以调试出错的原因。
此外,我们还指定了我们的卷和 PVC,以确保我们可以持久化在步骤中使用的任何文件。我们可以将下载的数据集保存到持久卷中用于模型训练,然后持久化训练好的模型以供后续模型服务步骤使用。
第一步,数据摄入步骤,非常直接。它只指定了容器镜像和数据摄入的 Python 脚本以执行。这个 Python 脚本是一行代码,包含tfds.load(name='fashion_mnist')以将数据集下载到容器的本地存储,该存储将被挂载到我们的持久卷上。
列表 9.33 数据摄入步骤
- name: data-ingestion-step
serviceAccountName: argo
container:
image: kubeflow/multi-worker-strategy:v0.1
imagePullPolicy: IfNotPresent
command: ["python", "/data-ingestion.py"]
下一个步骤是一个由多个子步骤组成的步骤组,其中每个子步骤代表特定模型类型(例如,基本 CNN、带有 dropout 的 CNN 和带有批归一化的 CNN)的分布式模型训练步骤。以下列表提供了定义所有子步骤的模板。对于多个模型的分布式训练步骤,这些步骤将并行执行。
列表 9.34 分布式训练步骤组
- name: distributed-tf-training-steps
steps:
- - name: cnn-model
template: cnn-model
- name: cnn-model-with-dropout
template: cnn-model-with-dropout
- name: cnn-model-with-batch-norm
template: cnn-model-with-batch-norm
让我们以第一个子步骤为例,该子步骤运行基本 CNN 模型的分布式模型训练。此步骤模板的主要内容是资源字段,它包括以下内容:
-
自定义资源定义(CRD)或清单,用于采取行动。在我们的案例中,我们创建一个 TFJob 作为此步骤的一部分。
-
指示 CRD 是否成功创建的条件。在我们的案例中,我们要求 Argo 监视
status.replicaStatuses.Worker.succeeded和status.replicaStatuses.Worker.failed字段状态。
在 TFJob 定义中的容器规范内部,我们指定模型类型并将训练好的模型保存到不同的文件夹,以便在后续步骤中轻松选择和保存最佳模型用于模型服务。我们还想确保附加持久卷,以便训练好的模型可以被持久化。
列表 9.35 CNN 模型训练步骤
- name: cnn-model
serviceAccountName: training-operator
resource:
action: create
setOwnerReference: true
successCondition: status.replicaStatuses.Worker.succeeded = 2
failureCondition: status.replicaStatuses.Worker.failed > 0
manifest: |
apiVersion: kubeflow.org/v1
kind: TFJob
metadata:
generateName: multi-worker-training-
spec:
runPolicy:
cleanPodPolicy: None
tfReplicaSpecs:
Worker:
replicas: 2
restartPolicy: Never
template:
spec:
containers:
- name: tensorflow
image: kubeflow/multi-worker-strategy:v0.1
imagePullPolicy: IfNotPresent
command: ["python",
"/multi-worker-distributed-training.py",
"--saved_model_dir",
"/trained_model/saved_model_versions/1/",
"--checkpoint_dir",
"/trained_model/checkpoint",
"--model_type", "cnn"]
volumeMounts:
- mountPath: /trained_model
name: training
resources:
limits:
cpu: 500m
volumes:
- name: training
persistentVolumeClaim:
claimName: strategy-volume
对于distributed-tf-training-steps中的其余子步骤,规范非常相似,只是保存的模型目录和模型类型参数不同。下一个步骤是模型选择,我们将提供相同的容器镜像,但执行我们之前实现的模型选择 Python 脚本。
列表 9.36 模型选择步骤 标题在此处
- name: model-selection-step
serviceAccountName: argo
container:
image: kubeflow/multi-worker-strategy:v0.1
imagePullPolicy: IfNotPresent
command: ["python", "/model-selection.py"]
volumeMounts:
- name: model
mountPath: /trained_model
确保这些额外的脚本包含在你的 Dockerfile 中,并且你已经重建了镜像并将其重新导入到你的本地 Kubernetes 集群中。
一旦模型选择步骤被实现,工作流中的最后一步是模型服务步骤,它启动一个 KServe 模型推理服务。它是一个类似于模型训练步骤的资源模板,但带有 KServe 的 InferenceService CRD 和一个适用于此特定 CRD 的成功条件。
列表 9.37 模型服务步骤
- name: create-model-serving-service
serviceAccountName: training-operator
successCondition: status.modelStatus.states.transitionStatus = UpToDate
resource:
action: create
setOwnerReference: true
manifest: |
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: flower-sample
spec:
predictor:
model:
modelFormat:
name: tensorflow
image: "emacski/tensorflow-serving:2.6.0"
让我们现在提交这个工作流!
列表 9.38 提交端到端工作流
> kubectl create -f workflow.yaml
一旦数据摄取步骤完成,相关的 Pod 将被删除。当我们再次列出 Pods,在它执行分布式模型训练步骤时,我们会看到以 tfjob-wf-f4bql-cnn-model-为前缀的 Pods,这些 Pod 负责监控不同模型类型的分布式模型训练状态。此外,每个模型类型的每个模型训练包含两个名为 multi-worker-training--worker-的工作者。
列表 9.39 获取 Pod 列表
> kubectl get pods
NAME READY STATUS RESTARTS AGE
multi-<truncated>-worker-0 1/1 Running 0 50s
multi-<truncated -worker-1 1/1 Running 0 49s
multi-<truncated -worker-0 1/1 Running 0 47s
multi-<truncated -worker-1 1/1 Running 0 47s
multi-<truncated -worker-0 1/1 Running 0 54s
multi-<truncated -worker-1 1/1 Running 0 53s
<truncated>-cnn-model 1/1 Running 0 56s
<truncated>-batch-norm 1/1 Running 0 56s
<truncated>-dropout 1/1 Running 0 56s
一旦剩余的步骤完成,并且模型服务启动成功,工作流应该具有“成功”状态。我们刚刚完成了端到端工作流的执行。
9.4.2 步骤记忆化
为了加快工作流的未来执行,我们可以利用缓存并跳过最近运行过的某些步骤。在我们的案例中,数据摄取步骤可以被跳过,因为我们不需要反复下载相同的数据集。
让我们先看看我们数据摄取步骤的日志:
Downloading and preparing dataset 29.45 MiB
(download: 29.45 MiB, generated: 36.42 MiB,
total: 65.87 MiB) to
/root/tensorflow_datasets/fashion_mnist/3.0.1...
Dataset fashion_mnist downloaded and prepared to /root/tensorflow_datasets/fashion_mnist/3.0.1\.
Subsequent calls will reuse this data.
数据集已经被下载到容器中的某个路径。如果这个路径被挂载到我们的持久卷上,它将可供任何未来的工作流运行。让我们使用 Argo Workflows 提供的步骤记忆化功能来优化我们的工作流。
在步骤模板内部,我们为 memoize 字段提供缓存键和缓存年龄。当步骤完成时,会保存一个缓存。当这个步骤在新工作流中再次运行时,它会检查缓存是否在过去一小时之内创建。如果是这样,这个步骤将被跳过,工作流将接着执行后续步骤。对于我们的应用,我们的数据集不会改变,所以理论上,缓存应该总是被使用,我们在这里只为了演示目的指定了 1 小时。在现实世界的应用中,你可能需要根据数据更新的频率来调整这个值。
列表 9.40 数据摄取步骤的记忆化
- name: data-ingestion-step
serviceAccountName: argo
memoize:
key: "step-cache"
maxAge: "1h"
cache:
configMap:
name: my-config
key: step-cache
container:
image: kubeflow/multi-worker-strategy:v0.1
imagePullPolicy: IfNotPresent
command: ["python", "/data-ingestion.py"]
让我们第一次运行这个工作流,并注意工作流节点状态中的“记忆化状态”字段。由于这是第一次运行该步骤,缓存没有命中。
列表 9.41 检查工作流的节点状态
> kubectl get wf tfjob-wf-kjj2q -o yaml
The following is the section for node statuses:
Status:
Nodes:
tfjob-wf-crfhx-2213815408:
Boundary ID: tfjob-wf-crfhx
Children:
tfjob-wf-crfhx-579056679
Display Name: data-ingestion-step
Finished At: 2023-01-04T20:57:44Z
Host Node Name: distml-control-plane
Id: tfjob-wf-crfhx-2213815408
Memoization Status:
Cache Name: my-config
Hit: false
Key: step-cache
Name: tfjob-wf-crfhx[0].data-ingestion-step
如果我们在一小时之内再次运行相同的流程,我们会注意到步骤被跳过(在“记忆化状态”字段中指示为 hit: true):
Status:
Nodes:
tfjob-wf-kjj2q-1381200071:
Boundary ID: tfjob-wf-kjj2q
Children:
tfjob-wf-kjj2q-2031651288
Display Name: data-ingestion-step
Finished At: 2023-01-04T20:58:31Z
Id: tfjob-wf-kjj2q-1381200071
Memoization Status:
Cache Name: my-config
Hit: true
Key: step-cache
Name: tfjob-wf-kjj2q[0].data-ingestion-step
Outputs:
Exit Code: 0
Phase: Succeeded
Progress: 1/1
Started At: 2023-01-04T20:58:31Z
Template Name: data-ingestion-step
Template Scope: local/tfjob-wf-kjj2q
Type: Pod
此外,请注意“完成时间”和“开始时间”戳是相同的。也就是说,这一步可以立即完成,无需从头开始重新执行。
Argo Workflows 中的所有缓存都保存在 Kubernetes ConfigMap 对象中。缓存包含节点 ID、步骤输出和缓存创建时间戳,以及此缓存最后被访问的时间戳。
列表 9.42 检查 configmap 的详细信息
> kubectl get configmap -o yaml my-config
apiVersion: v1
data:
step-cache: '{"nodeID":"tfjob-wf-dmtn4-
3886957114","outputs":{"exitCode":"0"},
"creationTimestamp":"2023-01-04T20:44:55Z",
"lastHitTimestamp":"2023-01-04T20:57:44Z"}'
kind: ConfigMap
metadata:
creationTimestamp: "2023-01-04T20:44:55Z"
labels:
workflows.argoproj.io/configmap-type: Cache
name: my-config
namespace: kubeflow
resourceVersion: "806155"
uid: 0810a68b-44f8-469f-b02c-7f62504145ba
摘要
-
数据摄取组件使用 TensorFlow 实现了 Fashion-MNIST 数据集的分布式输入管道,这使得它很容易与分布式模型训练集成。
-
机器学习模型和分布式模型训练逻辑可以在 TensorFlow 中定义,然后借助 Kubeflow 在 Kubernetes 集群中以分布式方式执行。
-
单实例模型服务器和复制模型服务器都可以通过 KServe 实现。KServe 的自动扩展功能可以自动创建额外的模型服务 Pod 来处理不断增加的模型服务请求。
-
我们在 Argo Workflows 中实现了我们的端到端工作流程,该工作流程包括我们系统中的所有组件,并使用步骤记忆化来避免耗时且冗余的数据摄取步骤。
第一部分 基本概念和背景
本书这一部分将提供一些与分布式机器学习系统相关的背景知识和概念。我们将从讨论机器学习应用的日益增长规模(鉴于用户对更快响应以满足现实生活需求的期望)开始,接着讨论机器学习管道和模型架构。然后我们将讨论什么是分布式系统,描述其复杂性,并介绍一个在分布式系统中经常使用的具体示例模式。
此外,我们将讨论分布式机器学习系统是什么,检查那些系统中常用的类似模式,并讨论一些现实生活中的场景。在这一部分的最后,我们将简要回顾本书我们将要学习的内容。
第二部分 分布式机器学习系统模式
现在你已经了解了分布式机器学习系统的基础概念和背景,你应该能够继续阅读本书的这一部分。我们将探讨机器学习系统各个组件中涉及的一些挑战,并介绍一些在工业界广泛采用的成熟模式来解决这些挑战。
第二章介绍了批处理模式,用于处理和准备大型数据集以进行模型训练;分片模式,用于将大型数据集分割成多个数据分片,这些分片分散在多个工作机器上;以及缓存模式,当重新访问先前使用的数据集进行模型训练时,它可以极大地加速数据摄取过程。
在第三章中,我们将探讨分布式模型训练过程中的挑战。我们将涵盖训练大型机器学习模型(这些模型标记了新 YouTube 视频中的主要主题,但无法在一个单独的机器上运行)的挑战。本章还将介绍如何克服使用参数服务器模式的困难。此外,我们将了解如何使用集体通信模式来加速较小模型的分布式训练,并避免参数服务器和工作节点之间不必要的通信开销。在本章的结尾,我们将讨论由于数据集损坏、网络不稳定和抢占式工作机器导致的分布式机器学习系统的某些漏洞,并探讨我们如何解决这些问题。
第四章重点介绍模型服务组件,它需要具备可扩展性和可靠性,以处理不断增长的用户请求数量和单个请求的大小。我们将讨论在设计决策中构建分布式模型服务系统的权衡。我们将使用复制服务来处理不断增长的模型服务请求。我们还将学习如何评估模型服务系统,并确定在现实场景中事件驱动设计是否会带来益处。
在第五章中,我们将了解如何构建一个系统,该系统执行复杂的机器学习工作流程,以训练多个机器学习模型,并使用扇入和扇出模式选择性能最佳的模型,以在模型服务系统中提供良好的实体标记结果。我们还将结合同步和异步模式,使机器学习工作流程更加高效,并避免由于长时间运行的模型训练步骤而导致的延迟。
第六章,本书这一部分的最后一章,涵盖了可以极大地加速端到端工作流程的一些操作努力和模式,以及当工程和数据分析团队协作时产生的维护和沟通努力。我们将介绍几种调度技术,以防止在许多团队成员在有限的计算资源相同的集群中工作时出现资源饥饿和死锁。我们还将讨论元数据模式的益处,我们可以利用它从机器学习工作流程的各个步骤中获得见解,并更恰当地处理故障,以减少对用户的不利影响。
第三部分:构建分布式机器学习工作流程
如果你已经成功完成了到目前为止的训练,恭喜你!你刚刚学到了许多可以在现实世界的机器学习系统中使用的常见模式,以及理解在决定将哪些模式应用于你的系统时的权衡。
在本书的最后部分,我们将构建一个端到端的机器学习系统,以应用之前学到的知识。我们将通过实施在此项目中之前学到的许多模式来获得实际操作经验。我们将学习如何在大规模上解决问题,并将我们在笔记本电脑上开发的内容扩展到大型分布式集群。
在第七章中,我们将介绍项目背景和系统组件。然后,我们将逐一探讨这些组件所面临的挑战,并分享我们将应用以解决这些挑战的模式。第八章涵盖了四种技术(TensorFlow、Kubernetes、Kubeflow 和 Argo Workflows)的基本概念,并提供了在每个技术中获取实际操作经验的机会,以准备我们最终项目的实施。
在本书的最后一章,我们将使用第七章中设计的架构来实现端到端的机器学习系统。我们将每个组件的完整实现都融入之前讨论的模式。我们将使用第八章中学到的技术来构建分布式机器学习工作流程的不同组件。


浙公网安备 33010602011771号