译自 Why we’re not using Kubernetes to scale our GPU workloads。作者 Eli Mernit 。
Beam 是一个函数即服务平台,允许开发人员快速在云上运行他们的 AI 应用程序。用户主要在我们的平台上运行 AI 和数据工作负载,我们目前在我们的 Python SDK 中暴露了两种自动缩放策略。
本文解释了我们如何在我们的无服务器系统中设置自动缩放策略以及我们不得不做出的一些权衡。
在其核心,自动缩放是一个控制问题。如果您以前曾与物理系统一起工作,您可能已经构建过一个控制器来管理系统的某个部分。
例如,也许您构建了一个温度控制器。该控制器具有一个循环,比较传感器(温度计)的当前温度与期望温度(设定点),并进行相应的调整。在温度控制器的情况下,您可能使用的是 PID,这只是一种控制器类型。PID 考虑传感器数据,对数据执行一些操作(传递函数),并产生一些输出,然后将其反馈到输入。在温度控制器的情况下,输入可能是应用于某个 HVAC 系统的信号。
自动缩放器(autoscaler )也是一个控制器。在无服务器工作负载的自动缩放世界中,我们可以定义一个传递函数,根据传感器数据的矢量对系统进行调整。在这个上下文中,该矢量本质上是我们正在收集的有关工作负载的度量的列表。这包括诸如平均和峰值任务持续时间、队列深度、当前副本数、最大副本数等。
当我们首次设置系统时,我们尝试使用各种 Kubernetes Pod 自动缩放策略。Pod 自动缩放要求设置节点自动缩放,使用诸如 Karpenter、Keda 或 Cluster Autoscaler 的框架。
Pod 自动缩放可以在垂直、水平或基于请求数量的基础上发生。
- 水平 Pod 自动缩放。其工作方式是您设置 CPU 和内存阈值,然后相应地添加 Pod。它非常简单易用,因为它只是一个 HPA 资源,很容易设置。这很好,但主要的缺点是您需要设置 Kubernetes。您还需要在应用程序中设置一些警报系统,以便在内存超过一定数量时进行自动缩放。
- 垂直 Pod 自动缩放。这是通过评估每个 Pod 的 CPU 和内存要求,动态调整 Pod。但它对同质工作负载进行了优化,并且它是实验性的,因此我们最终没有使用它。
我们很快意识到这两种方法对我们来说都不适用,因为我们的系统默认是无服务器的,这意味着我们的工作负载需要缩放到零。使用传统的基于 Kubernetes 的自动缩放,零缩放是不可能的,因为副本的最小数量为1。[1] 您可以通过将部署中的副本数量设置为零来解决此问题,但这不是理想的解决方案。
然后,我们尝试了 Knative,它实现了另一种称为基于请求的自动缩放的形式:
- 基于请求的自动缩放。自动缩放是基于正在进行的请求数量。这些数据在一个移动窗口中捕获,副本的数量相应增加。这是一种 HPA 的形式,但支持零缩放。它允许您说,好的,这是在添加另一个副本之前可以同时进行的请求数量。但这要求您知道每个副本在给定时间内可以处理多少请求。
Kubernetes 自动缩放方法的问题在于 CPU 和内存消耗仅是应用程序执行情况的间接度量。如果您正在扩展常规后端 API 或内部服务,其中 CPU 和内存是了解应用程序执行情况的良好指标,上述方法可能适用于您。
CPU 工作负载相对容易扩展。您可以通过向托管应用程序的 Web 服务器添加更多工作程序(进程)或添加更多副本并进行水平扩展来扩展它们。
然而,对于 GPU 工作负载来说,要做同样的事情要困难得多。有办法在多个工作负载之间共享单个 GPU,但我会在本文中略过这些。扩展 GPU 工作负载的最安全选项就是添加另一个 GPU。
考虑一个 ML 模型。让我们假设一个单独的 GPU 只能处理 X 请求数/分钟,而我们超过了这个阈值。然后,我们需要告诉我们的自动缩放器添加另一台机器 - 一旦该机器启动,我们的容器就开始运行,我们将不得不从磁盘加载模型权重,将这些权重加载到 RAM 中,然后最终加载到 GPU 上。
为了有效地扩展具有这种启动成本的工作负载,我们有一些技巧可以使这个过程更顺畅:
- 分析历史流量,试图在流量激增之前预测何时添加副本
- 优化加载新工作负载的启动成本。这涉及对我们如何加载容器映像并启动新机器的底层优化。
说到这一点,我们决定在 Beam 上实现两种自动缩放策略:基于队列大小的策略和基于请求延迟的策略。在幕后,我们正在通过我们构建的内容寻址存储系统以快速检索缓存的图像,从而在新副本上快速加载容器。
但从最终用户的角度来看,我们提供了两个简单的杠杆:
我们实施的第一种策略是基于队列深度的。
用户可以定义他们想在单个副本上运行多少任务。例如,如果用户指定每个副本的任务限制为5个,如果有5个请求,我们只需要1个副本。
这相当容易实现。我们只是将队列深度除以每个副本的任务数,得到的输出是一个整数,并取此数字与用户想要运行的最大副本数的最小值。
用户可以根据他们对应用程序的了解来控制此参数:
from beam import QueueDepthAutoscaler
autoscaling_config = QueueDepthAutoscaler(
max_tasks_per_replica=30,
max_replicas=3,
)
@app.rest_api(autoscaler=autoscaling_config)
def your_function():
...
这类似于按队列深度进行缩放,但更适用于个别用例。
与其说我们想要这些任务的最大数量,不如说您希望请求花费的最长时间是多少。
我们让人们有能力设置延迟为30秒,以及要运行的最大副本数:
from beam import RequestLatencyAutoscaler
autoscaling_config = RequestLatencyAutoscaler(desired_latency=30)
@app.rest_api(autoscaling=autoscaling_config)
def your_function():
...
所有工作负载都是不同的,没有适用于所有情况的自动缩放策略。
尽管我们最初尝试了基于 Kubernetes 的自动缩放用于我们的系统,但我们意识到基于 CPU 和内存的自动缩放策略并未考虑应用程序的实际行为。
到目前为止,基于请求延迟的自动缩放在不同的用例中表现得相当不错。归根结底,我们的用户并不关心他们的应用程序使用了多少 CPU 或内存。相反,他们关心的是请求是否被丢弃,以及他们的最终用户等待我们的 API 响应的时间有多长。
基于请求延迟的自动缩放使得自动缩放行为与最终用户体验非常紧密地联系在一起。
话虽如此,我们只是触及了可能的自动缩放策略的表面。我们计划在未来提供更多选择。
如果有人有想法,我们很乐意听取。您可以通过 founders [at] beam [dot] cloud 发送邮件给我们。
[1] 从技术上讲是可能的,但通常不适用于像 EKS 或 GKE 这样的托管服务。在 k8s 1.22 之前,这可能适用于 GKE。
还有一个自 k8s 1.16 开始提供的实验性 feature gate,称为 HPAScaleToZero。当您启用它时,还必须使用与部署的 pod 无关的外部指标来调整部署的规模。更多信息请参见此处。