本文要点
随着深度网络变得更加专业化,对资源的需求也越来越大,对于初创公司和规模化扩张的企业而言,在预算紧张的环境中为这些运行在加速硬件上的网络提供服务(serving)也变得越来越困难。
松散耦合的架构可能是更好的选项,因为它们在为深度网络提供服务时具有高度可控性、易适应性、透明可观察性和自动缩放性(成本效益较高)。
任何规模的公司都可以利用各种托管云组件(例如函数、消息服务和 API 网关),使用相同的服务基础架构来处理公共和内部请求。
托管消息代理带来了轻松的可维护性,无需专门的团队负责维护工作。
适应无服务器和松散耦合组件后,深度学习解决方案的开发和交付速度也可能会提升。
随着深度学习在许多行业的应用范围不断扩大,深度网络的规模和特异性也在增加。大型网络需要更多资源,并且由于它们的任务特定性(如定制的函数/层),将它们从急切执行(eager execution)编译为运行在 CPU 或 FPGA 后端上的优化计算图可能是无法做到的。因此,这种模型在运行时可能需要显式 GPU 加速和自定义配置。然而,深度网络都是在资源有限的约束环境中运行的,环境中云 GPU 的价格颇为昂贵,且低优先级(即竞价、抢占式)实例相当稀缺。
由于调用者和 API 处理者之间的紧密时间耦合,使用常见的机器学习服务框架将这些网络投入生产,可能会给机器学习工程师和架构师带来很多麻烦。这种情况是非常有可能出现的,特别是对于采用深度学习的初创公司和规模化扩张的公司来说更是如此。从 GPU 内存管理到缩放,他们在服务深度网络时可能会面临多个问题。
在本文中,我将重点介绍一种部署深度网络的替代方法的优势,这种方法会暴露基于消息的中介。这会放松我们在 REST/RPC API 服务中看到的紧密时间耦合,并带来异步运行的深度学习架构,在初创和扩张公司工作的工程师会更喜爱这种架构。
我将使用四大指标来对比这个服务架构与 REST 框架:可控性、适应性、可观察性和自动缩放性。我将进一步展示如何使用一系列现代云组件轻松地将 REST 端点添加到面向消息的系统中。本文将要讨论的所有想法都是云和语言无关的。它们也可以用在本地服务器上托管的场景中。
服务深度网络的挑战
深度网络是由一些高度级联的非线性函数组成的,这些函数形成应用于数据的计算图。在训练阶段,网络使用选定的输入/输出对以最小化选定目标的方式来调整这些图的参数。在推理时,输入数据简单地流过这个优化图。正如上述介绍所示,任何深度网络都要面对的一个明显的挑战就是它的计算密集度。知道了这一点后,你可能会惊讶地发现,基于紧密时间耦合的 REST/RPC API 调用是服务深度网络的最常见方式。
只要构成深度网络的图可以通过层的融合、量化或剪枝进行优化,基于 API 的服务就不会引发任何问题。然而,这种优化并不总能得到保证,尤其是在将研究网络转移到生产环境时往往会出现优化不足的问题。研发阶段产生的大多数想法都具有特异性,为优化计算图而创建的通用框架可能不适用于这种网络(例如,Pytorch/ONNX JIT 无法编译具有Swish激活函数的层)。
在这种情况下,由 REST API 带来的紧密耦合是不理想的。提高推理速度的另一种方法是将图编译到专用硬件上运行,这种专用硬件设计为能够很好地并行化图中执行的计算(例如 FPGA、ASIC)。但同样,由于自定义函数需要通过硬件描述语言(如 Verilog、VHDL)集成到 FPGA 中,因此特异性问题是没办法用这种方式来处理的。
考虑到深度网络还将继续扩大规模,并根据行业需求变得越来越专业化,预计在不久的将来推理时也会用上显式 GPU 加速了。因此,分离调用者和服务函数之间的同步接口,并允许高度可控的基于拉取的推理,在许多层面上都更具优势。
打破紧密的时间耦合
我们可以向系统添加额外的中间件服务来放松时间耦合。在某种程度上,这更像是通过电子邮件服务提供商与你的邻居交流,而不是在邻居家窗外喊话。使用消息中间件(例如 RabbitMQ、Azure 服务总线、AWS SQS、GCP Pub/Sub、Kafka、Apache Pulsar、Redis)后,目标现在可以完全灵活地处理调用者的请求(就像邻居可能会忽略你的电子邮件,直到他/她吃完晚餐为止)。这是特别有利的,因为从工程师的角度来看,它实现了高度可控。考虑在具有 8Gb 内存的 GPU 上部署 2 个深度网络(在推理时需要 3Gb 和 6Gb 内存)的情况。
在基于 REST 的系统中,可能需要事先采取预防措施来确保这两种模型的 worker 不会过度使用 GPU 内存,否则由于直接调用,某些请求将失败。另一方面,如果使用一个队列,worker 可能会选择推迟工作,等到稍后有内存可用时再处理。由于这是异步处理的,因而调用者不会被阻塞并且可以继续执行其工作。这种场景尤其适合公司内部的请求,例如时间约束相对宽松的请求,但这种队列也可以使用云组件(例如无服务器函数)实时处理客户端或合作伙伴的 API 请求,如下一节所述。
选择消息中介的 DL 服务的另一个令人信服的理由是其简单的适应性。如果想要充分利用 Web 框架和库的潜力,我们就一定要面对它们的学习曲线,即便是 Flask 这样的微框架也不例外。另一方面,我们并不需要了解消息中间件的内部结构,而且所有主流云供应商都提供自己的托管消息服务,于是工程师就用不着再管维护工作了。这在可观察性方面也有很多优势。由于消息传输通过显式接口与主要的深度学习 worker 分离,因此可以独立聚合日志和指标。在云上,这甚至可能都用不着了,因为托管消息传递平台会使用仪表板和警报等附加服务自动处理日志记录。这样的队列机制本身也适合自动缩放。
由于高可观察性,队列带来了选择如何自动缩放 worker 的自由。在下一节中,我们将使用KEDA(Kubernetes 事件驱动的自动缩放)展示 DL 模型的可自动缩放容器部署。它是一个开源的基于事件的自动缩放服务,旨在简化 K8s pod 的自动管理。它目前是一个云原生计算基金会(Cloud Native Computing Foundation)沙箱项目,支持多达 30 个缩放器,从 Apache Kafka 到 Azure 服务总线(Azure Service Bus)、AWS SQS 和 GCP Pub/Sub 都在支持之列。KEDA 的参数让我们可以根据传入的数据量(例如等待消息的数量、持续时间和负载大小)自由地优化缩放机制。
一个部署示例
在本节中,我们将使用 Pytorch worker 容器和 Azure 上的 Kubernetes 展示一个模板部署示例。除了网络权重、输入和可能的输出图像等大型工件外,数据通信将由 Azure 服务总线处理。它们应该存储在 blob 存储中,并使用用于 blob 的 Azure Python SDK 从容器下载/上传。该架构的高级概述见下图 1。
图 1:所提议架构的高级概述。对于每个块,括号中给出了相应的 Azure 服务。它可以处理使用无服务器函数的外部 REST API 请求和直接来自队列的内部请求。
我们将使用 Azure 服务总线队列和 KEDA 实现一个可自动缩放的竞争消费者模式服务器。要启用请求-回复模式来处理 REST 请求,可以使用Azure Durable Function外部事件。在示例架构中,我们假设一个持久函数已准备就绪,并通过服务总线队列将反馈事件回复 URL 传输到 worker 线程,Azure 文档中解释了设置此服务的细节。KEDA 允许我们使用队列长度设置缩放规则,这样 K8s 中的 worker pod 数量将根据负载自动更新。我们还将一个 worker 容器(或在我们的例子中的多个容器)绑定到一个 GPU 上,这样我们就可以自动缩放任何托管集群,并向我们的系统添加更多 GPU 机器,而不会出现任何麻烦。K8s 自动处理集群的自动缩放以解决资源约束(即由于 GPU 数量不足导致的节点压力)。
可以在这个Github存储库中找到描述如何为常规 ResNet 分类器提供服务的详细模板。文中将显示每个块的缩短版本。第一步,我们来创建我们的深度网络服务函数(network.py)。初始化推理函数的模板类可以写成如下形式,这可以根据手头的任务(例如分割、检测)进行定制:
在原始函数中,我们返回前 5 个 ImageNet 类别的类 ID。随后,我们准备编写我们的 worker Python 函数(run.py),这里我们将模型与 Azure 服务总线集成在一起。如下面的片段所示,用于服务总线的 Azure Python SDK 支持对传入的消息队列进行非常简单的管理。PEEK_LOCK 模式允许我们明确控制何时完成或放弃传入请求:
此时我们已经准备好了模板 worker,现在我们来创建容器的 Dockerfile 并将其推送到 Azure 容器注册表(Container Registry)。这里 requirements.txt 包含我们的 worker 的额外 pip 依赖项。通过 exec 运行主进程可以被视为一种确保它作为 PID 1 进程运行的技巧。这让集群能够在发生任何错误时自动重启 pod,而无需在部署 YAML 文件中写入显式活动端点。请注意,指定健康检查仍然是更好的做法:
创建 K8s 集群后,不要忘记从门户启用节点自动缩放特性(例如,最小 1,最大 8)。作为最后的准备步骤,我们需要在集群中启用GPU驱动程序(到 gpu-resources 命名空间)并通过官方 YAML 文件部署KEDA服务(到 keda 命名空间)。为方便读者,Keda 和 GPU 驱动程序 YAML 文件已包含在存储库中:
下一步,我们可以通过准备好的 shell 脚本部署 worker 容器。首先,我们创建命名空间来部署我们的服务:
请注意,使用 shell 文件而不是普通的 YAML 可以帮助我们轻松更改参数。运行部署脚本(deploy.sh)后,我们就准备就绪了(不要忘记根据你的需要设置参数):
由于我们限制每个 pod 使用一个 GPU,因此通过 KEDA 缩放 pod 也将有效地缩放集群节点。这会让整体架构具有很高的成本效益。在某些情况下,你甚至可以将最小节点数设置为零并在 worker 空闲时砍掉 GPU 成本。但是,我们做这种配置时必须非常小心,并考虑好节点的缩放时间。部署脚本中使用的 KEDA 参数的细节可以在官方文档中找到。在部署脚本(deploy.sh)中,如果你仔细查看,你会发现我们将 NVIDIA_VISIBLE_DEVICES 环境变量设置为“all”,来尝试从另一个容器(worker-1)访问 GPU。这个技巧让我们能够同时利用集群缩放和一个 pod 中的多个容器。如果不设置此项,由于 worker-0 的“limit”约束,K8s 将不允许为每个 GPU 添加更多容器。工程师应测量其模型的 GPU 内存使用情况,并根据 GPU 卡的限制添加容器。请注意,为简洁起见,图 1 中指定的 Azure 专属块的细节(示例服务总线接收器除外)没有展示出来。Azure 为每个组件提供了大量文档以及相关的 Python 示例实现。
未来发展方向
如果大家看过了计算技术在 20 世纪的演变过程,那么现在深度学习硬件研究中发生的事情可能会让人感到非常熟悉。一个显然容易实现的成果是翻译器的小型化,我们可以尽量将计算核心的数量控制在单芯片的水平上。最后,行业严重依赖 VLSI 改进而不是算法开发。看到为深度学习定制的硬件增长的速度如此之快,我们可能会期望 21 世纪复刻 20 世纪的历史。另一方面,在云中,无服务器加速的 DL 服务似乎是可以轻松摘取的果实。深度学习部署将进一步抽象化,按用量付费的初创公司将在不久的将来随处可见。由于松散耦合架构带来的灵活性,我们也可以合理预测松散耦合架构将减轻在此类初创公司工作的工程师的负担,因此我们可能会看到许多针对松散耦合架构的新生开源项目。
总结
在这篇文章中,我们描述了消息中介深度学习服务的四大好处:可控性、适应性、可观察性和自动缩放性(成本效益较高)。除此之外,我还提供了一个模板代码,可用于在 Azure 平台上部署文中所描述的架构。应该强调的是,这种服务的灵活性在某些场景中可能是不切实际的,例如物联网和嵌入式设备服务,在这些场景中组件的本地独立性过重。然而,这里提出的想法可以通过多种方式采用,例如我们可以使用低级 C/C+消息代理库在资源约束平台中创建类似的松散耦合架构,而不是使用云消息服务(例如用于自动驾驶、物联网需求)。
想在实践中尝试本文中的概念吗?你可以在Github上找到文章随附的代码。
作者介绍
Sabri Bolkar 是机器学习应用科学家和工程师,他对基于学习的系统从研发到部署和持续改进的整个生命周期感兴趣。他在挪威科技大学学习计算机视觉,并在比利时鲁汶大学完成了关于无监督图像分割的硕士论文。在荷兰代尔夫特理工大学攻读博士学位后,他目前正在攻关电子商务行业面临的大规模应用深度学习的挑战。读者可以通过他的网站与他联系。
评论