ML gpu model server 성능을 유지하며 cpu server로 전환한 경험 공유

o00o0oo
네이버 플레이스 개발 블로그
24 min readJul 11, 2023

--

Intro

안녕하세요, G플레이스AI개발팀 박상준, 이주영, 민준호입니다.

저희 팀에서는 한국, 일본 등의 지역기반서비스를 사용하시는 유저분들께 새롭고 더 개선된 가치를 제공하기 위해 여러가지 AI 모델을 서비스에 활용하고 있는데요.

여기서 그치지 않고 더욱 훌륭한 서비스 경험을 제공하기 위한 large model train, 그리고 더 다양한 모델 활용을 목표하고 있습니다.
한정된 gpu 자원 속에서 이러한 목표를 달성하기 위해 기존에 운영하던 비교적 작은 모델들을 cpu serving으로 전환할 필요가 있었습니다.

이번에 소개드릴 내용은 성능 하락 없이 이전과 동일한 서비스 퀄리티를 유지하면서 기존 model server들을 gpu에서 cpu로 전환하여 연간 약 4억원의 비용절감 효과를 볼 수 있었던 작업에 대한 경험입니다.

문제 정의

1 — 서비스 아키텍처

cpu server 전환 경험을 말씀드리기 전에 내용의 이해도를 돕기 위해 저희 서비스 아키텍처를 최대한 간략화하여 소개해 드리겠습니다.

이해를 돕기 위해 최대한 간소화 한 service architecture입니다

기본적으로 input을 model이 forward시킬 수 있는 형태로의 전처리 및 inference 결과를 client가 이해할 수 있는 형태로 후처리 하는 CPU intensive한 작업은 App Server(FastAPI)에서 수행하고, Model Server(TorchServe)는 순수하게 inference만을 수행하는 구조를 가지고 있습니다. 안정적인 서비스 운영을 위해 아래 동작이 충분한 처리량과 적절한 latency로 수행되어야 합니다.

  • client는 traefik gateway를 통해 app server로 요청
  • app server는 input을 resize, transform 등을 거쳐 torch tensor로 preprocess 후 model server로 요청
  • model server는 inference 수행 후 app server로 feature를 반환
  • app server는 feature를 사람이 이해하기 용이한 형태로 변환 postprocess 후 client에게 반환

2 — 처리량과 latency 측정

아래는 G플레이스AI개발팀에서 실제로 운영중이던 이미지 점수 측정 모델을 cpu로 배포한 뒤 단순비교한 결과입니다.
cpu가 비교적 저렴한 자원이기 때문에 pod를 3배 늘려서 비교했음에도 10배 낮은 rps와 10배 느린 response time을 확인할 수 있습니다.

cpu가 gpu보다 inference 성능이 안 좋은 것은 이제는 상식이기 때문에 놀라운 결과는 아니었지만, 매우 곤란한 상황임은 분명했습니다. 한정된 자원 안에서의 성능 유지를 목표하여 추가 scale out은 고려하지 않았기 때문에 대략 10~20배의 성능 향상이 필요했습니다.

3 — 애로사항 : 처리량 측면에서

쉽고 효과적이지만 추가 자원이 들어가는 scale in, out을 제외하고, 8core, 3replica를 고정해두었을 때, torchserve framework 사용자들이라면 당연하게도 torchserve의 worker를 늘리는 방안을 생각할 것입니다.
worker가 늘어나는만큼 memory usage가 선형적으로 증가한다는 점을 빼면, gpu server에서 실제로 효과적으로 작용하기 때문입니다.

하지만 실제로 적용을 해보고나면 당황스러울 정도로 처리량과 latency 지표가 오히려 악화돼버리는 현상을 마주할 수 있습니다.

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

4 — 애로사항 : latency 측면에서

더욱 문제는 latency였습니다. 처리량 측면에서는 최악의 경우 위에서는 제외하고 생각했던 scale out을 통해 아주 쉽고 간단하게 개선이 가능하기 때문입니다.
반면 latency는 개선이 불가능해 보였습니다. 예시로 든 이미지 점수 측정기 모델에 단일 inference만을 수행시키더라도 1초가 넘게 걸렸기 때문입니다. 더군다나 요청량이 많아지면 latency는 4초까지도 증가하게 됩니다.
client를 만족시킬 timeout 기준을 단일 inference로도 충족시키지 못 하는 상황이었습니다.

개선 방안

ML 측면과 engineering 측면 양쪽의 개선이 필요한 상황이었습니다.
cpu로 동작하는 inference time을 근본적으로 축소시켜야 했고, 일반적으로 성능을 향상시키는 config 조정에서 성능이 오히려 하락하는 비정상 동작의 원인을 찾고 최적의 설정값을 찾아야 했습니다.
이를 위해 MLE분들과 협업하여 latency 측면에서의 inference time 최적화 작업과, 최적 설정값을 찾는 engineering 작업을 동시에 진행한 뒤 중첩 적용하여 cpu server로 전환하자는 목표를 세우고 진행하게 되었습니다.

1 — engineering 측면에서 낮은 rps 해소

먼저 worker num을 늘렸음에도 성능이 감소하는 이유를 먼저 생각해 봤습니다.
일반적으로 worker num을 늘림으로써 기대할 수 있는 개선 효과는 병렬성의 증가일텐데요.
성능 향상을 기대하고 병렬성을 늘렸는데 반대로 성능이 하락한다면 그에 상응하는 trade-off 효과가 있음을 짐작할 수 있습니다.

기본으로 돌아가서 model inference에서 cpu가 gpu보다 성능이 좋지 않은 이유를 돌이켜보면, 하드웨어 설계 차이로 인한 multi-threading 수행 능력 차이일텐데요.

이미지 출처 : nvidia

좀 더 자세히 들어가자면 model inference는 근본적으로 GEMM(General Matrix Multiply) 연산의 반복이고, 이런 GEMM 연산은 fused-multiply-add(FMA) 또는 dot-product(DP) execution unit으로 독립적으로 연산되기 때문입니다.
이 GEMM 연산이 cpu에서 병목을 일으킨다면 병렬성을 증가시켰을 때 성능이 오히려 하락하는 원인이 될 수 있을 것이라 가정하고 좀 더 리서치를 진행한 결과 pytorch 문서 중 다음 내용을 찾을 수 있었습니다.

while two logical threads run GEMM at the same time, they will be sharing the same core resources causing front end bound

logical thread가 CPU GEMM 연산에 병목을 발생시킬 수 있다는 내용인데요. 이 내용을 보고 worker num을 늘렸을 때 성능이 하락하는 원인을 직관적으로 이해할 수 있었습니다.
바로 torch thread의 default 값이 cpu의 physical core 값으로 되어있기 때문입니다.

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을 늘리면 총 thread 수가 phisycal core * worker_num만큼 늘어나면서 결론적으로 logical thread가 사용되게 되는 것입니다.

해당 내용을 조치하면 실제로 성능향상을 확인할 수 있었습니다. 아래는 worker_num을 4로 늘리고 총 thread 수를 physical core 수에 맞췄을 때의 지표로 rps가 6.3으로 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 : 저희 팀은 k8s 위에서 서버를 운영중이기 때문에 lscpu 명령어로 확인할 수 있는 node의 physical core가 아닌 pod의 cpu resource limit에 맞춰주어야 했습니다.(각 worker의 torch thread를 8/4 = 2로 설정, 24/4 = 6으로 설정시 성능 하락)

주의점 2 : worker마다 torch thread를 설정해 줄 때 int로만 설정할 수 있기 때문에 cpu usage를 충분히 활용하기 위해서는 cpu limit이 worker_num에 나누어 떨어지게 설정하는 것이 좋습니다.

ex) core=8, worker_num=3인 경우 : int(8/worker_num) = 2, 2*worker_num/8 = 75%
ex) core=8, worker_num=4인 경우 : int(8/worker_num) = 2, 2*worker_num/8 = 100%

하지만 worker를 4배 늘렸는데 성능이 3배 향상에 그치는 것이 조금 아쉬운데요. 그 원인은 model server에 접속해서 htop 명령어로 core 사용률을 확인해보면 알 수 있었습니다.

physical core가 24개이기 때문에 25번부터 48번 core는 logical core임을 주의하고 본다면 원인을 알 수 있습니다.
총 thread 수는 의도대로 2 *4 = 8로 실행되고 있지만 thread가 어느 core에 스케줄링 될지는 os의 몫이기 때문에 동일한 비율로 logical core에서 logical thread로 연산이 실행될 때가 있었던 것입니다.

physical core에서만 thread가 실행되게 할 수 있으면 성능 향상의 여지가 더 있어 보였습니다.
다행이 방법은 쉽게 찾을 수 있었는데요. CPU GEMM 병목을 경고했던 pytorch-geometric article의 참고 문서 중에 솔루션이 있었습니다.

참고 문서 : GROKKING PYTORCH INTEL CPU PERFORMANCE FROM FIRST PRINCIPLES

해당 문서의 내용에 따르면 ipex라는 intel의 솔루션을 도입하면 간단하게 core를 특정 socket에 pinning할 수 있습니다.
적용 방법도 매우 간단합니다. torchserve config.properties에 아래 설정을 추가해주면 됩니다.

ipex_enable=true
cpu_launcher_enable=true

더 좋은 점은 socket pinning으로 logical thread를 제거하는 것 외에 추가 효과를 기대할 수 있다는 점이었습니다.

cpu에 socket이 하나가 아니기 때문에 socket 1에 스케쥴링 됐던 thread가 socket 2에 재 스케쥴링 됐을 경우 cache hit이 일어날 때 Intel Ultra Path Interconnect(UPI)를 통해 socket 1의 cahce에 접근하게 됩니다. 이때 UPI는 local cache 접근보다 2배 이상 느리기 때문에 추가 병목이 발생하게 됩니다.
ipex는 thread를 socket 단위로도 pinning 해주기 때문에 이런 추가 병목을 같이 제거할 수 있었고 rps는 8 수준으로 초기 수준의 4배의 성능향상을 볼 수 있었습니다.

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 : ipex는 neural network(이하 nn으로 총칭) inference 최적화에 특화 돼있기 때문에 nn외의 추가 기법에는 성능 향상이 미미할 수 있습니다. 실제로 지금까지 예시로 든 이미지 점수 측정기의 경우 inference 후에 svr을 추가로 거치기 때문에 성능향상이 4배에 그쳤지만 순수 nn inference 모델인 음식 인식기 모델의 경우 7배(2.5rps -> 17.5rps)의 성능 향상을 볼 수 있었습니다.

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 : ipex 적용은 torchserve version이 0.6.1 이상이어야 적용이 됩니다. 저희 팀은 0.6.0을 쓰고 있었기 때문에 socket pinning이 정상 동작하지 않는 이슈가 있었습니다.
한참을 삽질하다 code를 직접 뜯어보고 나서야 이슈를 처리했던 고통이 기억나네요🥲

WorkerLifeCycle.java 에서 multi worker pinning 미지원(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));
}

현재는 해당 가이드 문서를 저희가 수정해두어서 가이드에 required version이 명시되어 있습니다.

2 — model 경량화를 통한 느린 latency 해소

두 번째로 느린 latency를 해소하기 위해서는 model 자체를 경량화 할 필요가 있었습니다.

model 경량화 기법은 대표적으로 pruning 기법과 Knowledge Distillation(이하 kd로 통칭)이 있는데요. 저희는 정확도가 중요한 서비스기 때문에 pruning은 제외하였습니다.
다들 아시겠지만 kd기법을 간단히 소개해드리자면 큰 네트워크(Teacher network)의 지식을 실제로 사용하고자 하는 작은 네트워크(Student network)에게로 전달하여 모델을 경량화 하는 기법입니다. 더 자세한 내용은 해당 개념이 처음 제시 된 Distilling the Knowledge in a Neural Network 논문을 참조바랍니다.

저희는 정확도 손실을 최소화하는 것이 최우선 과제였기 때문에 kd 기법 중에서도 여러가지를 검토하였는데요. 그 중에서 저희가 채택한 논문은 22년 발표된 Knowledge Distillation from A Stronger Teacher 입니다.

아이디어는 간단합니다. model의 prop값만 사용하여 distillation 하는 기존 방식에 비해 teacher의 class간의 correlation 까지 student가 학습하도록 하여 성능이 teacher에 근접하게 한다는 내용입니다. 실제로 적용해보았을 때 모델을 효과적으로 경량화하면서도 정확도를 최대한 유지하는 것을 확인 할 수 있었습니다.

아래는 저희가 해당 kd 기법을 여러 student model 후보군에 적용해보고 정확도가 유지되는 수준에서 선정한 결과입니다.

이미지 점수 측정기의 경우는 input size를 줄이는 추가 조치가 있기도 했습니다. 기존에는 SVR 이라는 CPU 기반 ML 기법을 사용했기 때문에 (2stage: CNN + SVR) 이를 1stage model로 경량화를 하더라도 CPU inference에서 속도적인 이점이 크지 않았기 때문입니다.

Inference를 진행할 student 모델의 input size를 더 줄일 수 있어야 경량화 의미가 있다고 생각했고, 그래서 사이즈를 384*384에서 224*224로 줄여서 실험을 진행하였습니다.

384 사이즈로 받던 이미지를 224로 줄이자 정확도가 일부 하락하는 trade-off가 있었는데요.(실험 도중 Img_Resize: 224로 변경시 MAE = 0.4007 → 0.4296로 성능 하락 발생.)

input size가 줄어들었기 때문에 기존 학습에서 적용했던 이미지에 변형을 가하는 여러가지 전처리(Affine, RandomRotate90, Blur, OneOf [GridDistortion, OpticalDistortion, ElasticTransform], VerticalFlip 등)들이 오히려 역효과를 낼 수 있다고 추정하였습니다.

때문에 transform을 간결화시키는 조치로 기존 2stage(cnn + svr) 방식을 큰 사이즈의 ConvNext로 1stage 통합 후 가벼운 EfficientNet으로 kd 하는 방식을 취했습니다. 추정이 맞아떨어져서 Student를 효과적으로 학습시킬 수 있었고 MAE 값이 기존보다 오히려 25%(.518->.3876) 개선되는 효과를 볼 수 있었습니다.

검증

1 — 최종 성능 측정

아래는 여러가지 모델 중에 해당 글에서 예시로 든 세 가지 모델에서 측정한 cpu serving 최종 개선 성능입니다.

# 음식 사진 분류기(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
# 이미지 점수 측정기(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
# 영수증 분류기(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 — traffic mirroring

글을 시작할 때 저희 팀 서비스에 간략한 아키텍처를 소개해드린대로 app-server 앞 단에 traefik이라는 도구를 gateway로 사용하고 있습니다. 최종 검증은 이 traefik gateway의 mirroring 기능을 이용하여 production으로 들어오는 traffic을 staging으로 mirroring 하여 한달 간 검증 후 production 까지 적용하여 운영 중입니다.

mirroring에 대해서는 본 주제를 벗어나는 내용이라 생략하겠습니다. 궁금하신 분은 https://doc.traefik.io/traefik/routing/services/#mirroring-service 해당 문서 참고 부탁드립니다.

정리

여기까지 gpu model server를 서비스 퀄리티를 유지하며 cpu server로 전환한 경험에 대한 내용이었습니다.
해당 작업을 통해 저희 팀은 staging, production을 합쳐서 한국, 일본 각각에서 15개의 gpu를 절약할 수 있었고, 대략 연간 약 4억원을 절약하는 효과를 볼 수 있었습니다.
저희는 Naver 내부에서 gpu를 직접 구매하여 사용하지만 대략적인 비용 절감을 산정하기 위해 t4 gpu를 안정적으로 서비스 할 수 있는 AWS EC2 인스턴스 기준으로 계산하였습니다.

계산식 : 1.306(1년 예약 인스턴스 실질 시간당 비용) * 1200(환율) * 24(시간) * 365(일) * 15(gpu 개수) * 2(KR + JP)

이렇게 확보한 gpu들은 저희 팀의 AI 서비스를 지속적으로 발전시켜 더욱 개선되고 특별한 서비스 경험을 제공하는 데 활용 할 계획입니다. 많은 격려와 기대 부탁드립니다:)

참고자료

https://blogs.nvidia.com/blog/2009/12/16/whats-the-difference-between-a-cpu-and-a-gpu/
https://pytorch-geometric.readthedocs.io/en/latest/advanced/cpu_affinity.html#binding-processes-to-physical-cores
https://www.intel.com/content/www/us/en/docs/vtune-profiler/user-guide/2023-0/cpu-metrics-reference.html#FRONT-END-BOUND
https://pytorch.org/tutorials/intermediate/torchserve_with_ipex.html
https://arxiv.org/pdf/2205.10536.pdf
https://aws.amazon.com/ko/ec2/instance-types/g4/

--

--