作者:Sangjune Park (Naver GplaceAI MLOps), Jooyoung Lee (Naver GplaceAI MLE), Junho Min (Naver GplaceAI MLE)

审阅者:Yunsang Ju (Naver GplaceAI Leader), Min Jean Cho (Intel), Jing Xu (Intel), Mark Saroufim (Meta)

引言

在这里,我们将分享把 AI 工作负载从 GPU 服务器迁移到 Intel CPU 服务器的经验,且没有性能或质量下降,同时在此过程中每年节省成本约 34 万美元(详见结论)。

我们旨在通过提供增强线上到线下 (O2O) 体验的各种 AI 模型来为消费者提供价值。随着对新模型的持续增长的需求以及高成本资源 GPU 的有限性,我们需要将相对轻量级的 AI 模型从 GPU 服务器迁移到 Intel CPU 服务器,以降低资源消耗。然而,在相同设置下,CPU 服务器存在问题,其 rps、推理时间等性能下降了几十倍。我们应用了各种工程技术并对模型进行了轻量化,以解决此问题,并通过仅仅三倍的横向扩展,成功迁移到性能与 GPU 服务器相同或更优的 Intel CPU 服务器。

有关我们团队的更详细介绍,请参阅NAVER Place AI 开发团队介绍

我在中间会再次提及,但总体工作得到了 Intel 和 PyTorch 撰写的从第一性原理理解 PyTorch Intel CPU 性能的大力帮助。

问题定义

1: 服务架构

Simplified service architecture

简化服务架构(图片来源: NAVER GplaceAI)

为便于理解,简要介绍一下我们的服务架构。CPU 密集型任务,例如将输入预处理为 tensor 格式(然后转发给模型)以及将推理结果后处理为人类可读的输出(例如自然语言和图像格式),在 App 服务器 (FastAPI) 上执行。模型服务器 (TorchServe) 专门处理推理操作。为确保服务的稳定运行,需要以足够的吞吐量和低延迟执行以下操作。

具体处理顺序如下

  • 客户端通过 Traefik 网关向应用服务器提交请求。
  • 应用服务器通过执行调整大小、变换等操作对输入进行预处理,并将其转换为 Torch tensor,然后请求模型服务器。
  • 模型服务器执行推理并将特征返回给应用服务器
  • 应用服务器通过后处理将特征转换为人类可理解的格式并返回给客户端

2: 吞吐量和延迟测量

Comparison of Image Scoring Models

图像评分模型对比

在所有其他条件不变的情况下,部署在规模增加了三倍的 CPU 服务器 pod 上,然而,值得注意的是,RPS(每秒请求数)和响应时间下降了十倍以上。虽然 CPU 推理性能不如 GPU 并不令人惊讶,但这种情况显然带来了挑战。考虑到在有限资源内保持性能的目标,需要在没有任何额外扩展的情况下,实现大约 10 到 20 倍的性能提升

3: 从吞吐量角度看挑战

Type     Name                                                                          # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
POST     /predictions/image-scoring                                                        37     0(0.00%) |   9031    4043   28985   8200 |    1.00        0.00
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
         Aggregated                                                                        37     0(0.00%) |   9031    4043   28985   8200 |    1.00        0.00

TorchServer 框架用户为了提高吞吐量,可能会采取的第一个步骤是增加 TorchServe 中的 worker 数量。这种方法在 GPU 服务器上是有效的,因为它能够进行并行工作负载处理,且 worker 扩展时只会增加线性内存使用。然而,我们在增加 worker 数量时却遇到了更差的性能。确定 CPU 服务器上性能下降的原因需要进一步调查。

4: 从延迟角度看挑战

我们的主要关注点是延迟。通常,当系统实现遵循横向扩展原则时,吞吐量提升是可以实现的,除非是极少数的最坏情况。然而,在图像评分模型的示例中,即使执行单次推理也需要超过 1 秒,并且随着请求量增加,延迟甚至增加到 4 秒。这是一个即使单次推理也无法满足客户端超时标准的情况。

提出的解决方案

需要从 ML 和工程两个角度进行改进。从根本上减少 CPU 上的推理时间,并找出在应用通常能提升性能的配置时导致性能下降的原因,从而找到最优配置值,这一点至关重要。为了实现这一目标,我们与 MLE 专业人员建立了合作,并行执行‘模型轻量化但不影响性能’和‘识别实现峰值性能的最优配置’等任务。通过上述方法,我们成功地将工作负载处理转移到了我们的 CPU 服务器上。

1: 从工程角度解决低 RPS 问题

首先,即使增加 worker 数量后性能仍然下降的原因是 GEMM 操作中逻辑线程导致的前端瓶颈。一般来说,增加 worker 数量的预期改进效果是并行性的提升。相反,如果性能下降,则可以推断出相应的权衡效应。

CPU + GPU

图片来源: Nvidia

正如许多人所知,CPU 上模型推理性能不如 GPU 的原因在于硬件设计的差异,特别是在多线程能力方面。更深入地说,模型推理本质上是 GEMM(通用矩阵乘法)操作的重复,而这些 GEMM 操作在“融合乘加”(FMA)“点积”(DP)执行单元中独立执行。如果 GEMM 操作成为 CPU 的瓶颈,增加并行性实际上可能会导致性能下降。在研究这个问题时,我们在PyTorch 文档中发现了相关信息。

当两个逻辑线程同时运行 GEMM 时,它们会共享相同的核心资源,导致前端瓶颈

此信息强调了逻辑线程可能导致 CPU GEMM 操作出现瓶颈,这有助于我们直观地理解为什么在增加 worker 数量时性能会下降。这是因为 torch 线程的默认值对应于 CPU 的物理核心数。

root@test-pod:/# lscpu
  …
Thread(s) per core: 2
Core(s) per socket: 12
  …
root@test-pod:/# python
>>> import torch
>>> print(torch.get_num_threads())
24

当 worker_num 增加时,总线程数会增加,其值为物理核心数 * worker 数量的乘积。因此,逻辑线程被利用。为了提升性能,我们将每个 worker 的总线程数调整为与物理核心数对齐。如下所示,当 worker_num 增加到 4 并将总线程数与物理核心数对齐时,指标 RPS 从之前的值 2.1 增加了大约三倍到 6.3。

Type     Name                                                                          # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
POST     /predictions/image-scoring                                                       265     0(0.00%) |   3154    1885    4008   3200 |    6.30        0.00
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
         Aggregated                                                                       265     0(0.00%) |   3154    1885    4008   3200 |    6.30        0.00

注意事项 1: 我们的团队使用 Kubernetes 来维护我们的部署。因此,我们根据 pod 的 CPU 资源限制进行调整,而不是使用 lscpu 命令检查的节点的物理核心数。(将每个 worker 的 torch 线程设置为 8/4 = 2 或 24/4 = 6 会导致性能下降。)

注意事项 2: 由于每个 worker 的 torch 线程设置只能配置为整数,因此建议将 CPU 限制设置为 worker_num 的倍数,以便充分利用 CPU 使用率。

example

例)core=8, worker_num=3 的情况下:int(8/worker_num) = 2, 2*worker_num/8 = 75%

example

例)core=8, worker_num=4 的情况下:int(8/worker_num) = 2, 2*worker_num/8 = 100%

我们还分析了模型容器,以了解为什么尽管 worker 数量增加了四倍,性能却只提高了三倍。我们监控了各种资源,其中,核心利用率被确定为根本原因。

threads

即使将总线程数调整为与 CPU(第二代 Intel(R) Xeon(R) Silver 4214)限制(8 核)匹配,仍然存在计算从逻辑线程执行到逻辑核心的情况。由于存在 24 个物理核心,编号为 25 到 48 的核心被归类为逻辑核心。将线程执行仅限制在物理核心内的可能性似乎提供了进一步提升性能的潜力。对该解决方案的参考可以在 PyTorch-geometric 文章中提及的、警告 CPU GEMM 瓶颈的源文档中找到。

根据文档中的说明,Intel 提供了适用于 PyTorch 的 Intel® Extension,我们可以简单地将核心固定到特定的 socket。应用方法也非常简单,只需将以下设置添加到 torchserve config.properties 文件即可。(使用了 intel_extension_for_pytorch==1.13.0)

ipex_enable=true
CPU_launcher_enable=true

two-socket configuration

图片来源: PyTorch

除了通过 socket 固定消除逻辑线程外,还有一个额外效果是消除了 UPI 缓存命中开销。由于 CPU 包含不止一个 socket,当在 socket 1 上调度的线程在 socket 2 上重新调度时,通过 Intel Ultra Path Interconnect (UPI) 访问 socket 1 的缓存会导致缓存命中。此时,UPI 访问本地缓存的速度会比本地缓存访问慢两倍多,从而导致更多瓶颈。通过 oneAPI 驱动的适用于 PyTorch 的 Intel® Extension 将线程固定到 socket 单元,我们观察到 rps 处理能力比存在瓶颈时提高了高达四倍

Type     Name                                                                          # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
POST     /predictions/image-scoring                                                       131     0(0.00%) |   3456    1412    6813   3100 |    7.90        0.00
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
         Aggregated                                                                       131     0(0.00%) |   3456    1412    6813   3100 |    7.90        0.00

注意事项 1: 适用于 PyTorch 的 Intel® Extension 专注于神经网络(下文简称“nn”)推理优化,因此 nn 之外的额外技术带来的性能提升可能很小。事实上,在以图像评分系统为例的实例中,推理后应用了 svr(支持向量回归),性能提升仅限于四倍。然而,对于像食物识别模型这样的纯 nn 推理模型,则检测到七倍的性能提升(2.5 rps -> 17.5 rps)

Type     Name                                                                          # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
POST     /predictions/food-classification                                                 446     0(0.00%) |   1113     249    1804   1200 |   17.50        0.00
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
         Aggregated                                                                       446     0(0.00%) |   1113     249    1804   1200 |   17.50        0.00

注意事项 2: 应用适用于 PyTorch 的 Intel® Extension 需要 torchserve 版本 0.6.1 或更高。由于我们团队使用的是 0.6.0 版本,存在 socket 固定功能未正常工作的问题。目前,我们已经修改了指导文档,并指定了所需的版本。

WorkerLifeCycle.java,0.6.0 及更早版本不支持多 worker 固定(ninstance 硬编码为 1)

// 0.6.0 version

public ArrayList<String> launcherArgsToList() {
   ArrayList<String> arrlist = new ArrayList<String>();
   arrlist.add("-m");
   arrlist.add("intel_extension_for_pytorch.cpu.launch");
   arrlist.add(" — ninstance");
   arrlist.add("1");
   if (launcherArgs != null && launcherArgs.length() > 1) {
     String[] argarray = launcherArgs.split(" ");
     for (int i = 0; i < argarray.length; i++) {
       arrlist.add(argarray[i]);
     }
   }
   return arrlist;
 }
// master version

if (this.numWorker > 1) {
   argl.add(" — ninstances");
   argl.add(String.valueOf(this.numWorker));
   argl.add(" — instance_idx");
   argl.add(String.valueOf(this.currNumRunningWorkers));
 }

2: 通过模型轻量化解决延迟缓慢的问题

我们还使用知识蒸馏(通常缩写为 KD)来精简模型,进一步降低延迟。众所周知,知识蒸馏是一种将来自较大网络(教师网络)的知识传递给一个更小、轻量级的网络(学生网络)的技术,后者资源消耗更少,更易于部署。有关更详细信息,请参阅最初介绍此概念的论文,题为《Distilling the Knowledge in a Neural Network》

neural networks

有多种可用的 KD 技术,由于我们主要关注准确率损失最小化,我们采用了 2022 年发表的论文《Knowledge Distillation from A Stronger Teacher》中的方法。这个概念很简单。与仅利用模型 prop 值的传统蒸馏方法不同,所选择的方法让学生网络学习教师网络中类别之间的相关性。在实际应用中,我们观察到模型权重得到了有效降低,同时保持了高准确率。以下是我们对几个候选学生模型使用上述知识蒸馏技术进行的实验结果,选择是基于保持的准确率水平进行的。

table of services

对于图像评分系统,我们采取了额外措施来减小输入大小。考虑到之前使用了基于 CPU 的 ML 技术 SVR(支持向量回归)(两阶段:CNN + SVR),即使将其精简为一阶段模型,在 CPU 推理中也未观察到显著的速度优势。为了使精简具有意义,推理时学生模型的输入大小需要进一步减小。因此,我们进行了将大小从 384384 减小到 224224 的实验。

为了进一步简化变换,我们将两阶段(CNN + SVR)方法统一为一个使用更大 ConvNext 的一阶段模型,然后使用轻量级 EfficientNet 应用 kd 来解决准确率权衡问题。实验过程中,我们遇到了一个问题,将 Img_resize 更改为 224 后,MAE 从 0.4007 下降到 0.4296,性能有所下降。由于输入大小的减小,应用于原始训练图像的各种预处理技术(例如 Affine, RandomRotate90, Blur, OneOf [GridDistortion, OpticalDistortion, ElasticTransform], VerticalFlip)产生了反作用。通过采取这些措施,我们成功地训练了学生模型,MAE 值与之前相比 (.518 到 .3876) 提高了 25%

验证

1: 最终性能测量

下表显示了使用 CPU 服务器,本文中提到的三种模型的最终性能改进。

# Food photo classifier (pod 3): 2.5rps -> 84 rps

 Type Name                                                                           # reqs # fails | Avg Min Max Med | req/s failures/s
 --------|----------------------------------------------------------------------------|------|------------|-------|------|-------|-------|--------|--------- 
POST /predictions/food-classification 2341 0(0.00%) | 208 130 508 200 | 84.50 0.00 
--------|----------------------------------------------------------------------------|--------|-------------|------|-------|--------|------|--------|----------
         Aggregated                                                                      2341     0(0.00%) |    208     130     508    200 |   84.50        0.00

# Image scoring (pod 3): 2.1rps -> 62rps
 Type Name                                                                               #reqs #fails | Avg Min Max Median | req/s failures/s
 --------|---------------------------------------------------------------------------------|--------|-------------|--------|-------|--------|---------|--------|--------- 
  POST /predictions/image-scoring 1298 0 (0.00%) | 323 99 607 370 | 61.90 0.00 
--------|---------------------------------------------------------------------------------|--------|-------------|--------|------|--------|---------|--------|----------
          Aggregated                                                                          1298     0(0.00%)  |     323      99     607     370  |   61.90        0.00

# receipt classifier(pod 3) : 20rps -> 111.8rps
Type     Name                                                                          # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
POST     /predictions/receipt-classification                                             4024     0(0.00%) |    266     133    2211    200 |   111.8        0.00
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
         Aggregated                                                                      4020     0(0.00%) |    266     133    2211    200 |   111.8        0.00

2: 流量镜像

如前所述,我们团队的服务架构使用了工具“traefik”作为应用服务器前的网关,本文开头已简要介绍。为了最终验证,我们利用 traefik 网关的镜像功能,将生产流量镜像到 staging 环境进行了一个月的验证,然后才将其应用于生产环境,目前已投入运营。

关于镜像的详细信息超出了本文的范围,因此省略。感兴趣的读者,请参阅文档:https://doc.traefik.io/traefik/routing/services/#mirroring-service

结论

本文结束了关于在保持服务质量的同时,从 GPU 模型服务器迁移到 CPU 服务器的讨论。通过这项努力,我们团队在韩国和日本各节省了 15 块 GPU,每年节省成本约 34 万美元。虽然我们在 NAVER 内部直接购买和使用 GPU,但我们基于稳定支持 T4 GPU 的 AWS EC2 实例 计算了大致的成本降低。

instance sizes

计算方法:1.306(1 年预留实例有效每小时成本)* 24(小时)* 365(天)* 15(GPU 数量)* 2(KR + JP)

这些节省下来的 GPU 将被用于进一步推进和增强我们团队的 AI 服务,提供卓越的服务体验。我们衷心感谢您的鼓励和期待:)

了解更多