微信 NLP 算法微服务治理( 二 )


但这些方法对于实验性服务就不那么合适了,因为实验性服务会频繁更新,我们无法对每一个新模型都去做新的优化 。针对实验性服务,我们针对 GPU 混合部署场景,自研了 Python 解释器 —— PyInter 。实现了不用修改任何代码,直接用 Python 脚本上线,同时可以获得接近甚至超过 C++ 的性能 。
 

微信 NLP 算法微服务治理

文章插图
 
我们以 Huggingface 的 bert-base 为标准,上图的横轴是并发进程数,表示我们部署的模型副本的数量,可以看出我们的 PyInter 在模型副本数较多的情况下 QPS 甚至超越了 onnxruntime 。
 
微信 NLP 算法微服务治理

文章插图
 
通过上图,可以看到 PyInter 在模型副本数较多的情况下相对于多进程和 ONNXRuntime 降低了差不多 80% 的显存占用,而且大家注意,不管模型的副本数是多少,PyInter 的显存占用数是维持不变的 。
我们回到之前比较基础的问题:Python 真的慢吗?
没错,Python 是真的慢,但是 Python 做科学计算并不慢,因为真正做计算的地方并非 Python,而是调用 MKL 或者 cuBLAS 这种专用的计算库 。
那么 Python 的性能瓶颈主要在哪呢?主要在于多线程下的 GIL(Global Interpreter Lock),导致多线程下同一时间只能有一个线程处于工作状态 。这种形式的多线程对于 IO 密集型任务可能是有帮助的,但对于模型部署这种计算密集型的任务来说是毫无意义的 。
 
微信 NLP 算法微服务治理

文章插图
 
那是不是换成多进程,就能解决问题呢?
 
微信 NLP 算法微服务治理

文章插图
 
其实不是,多进程确实可以解决 GIL 的问题,但也会带来其它新的问题 。首先,多进程之间很难共享 CUDA Context/model,会造成很大的显存浪费,这样的话,在一张显卡上部署不了几个模型 。第二个是 GPU 的问题,GPU 在同一时间只能执行一个进程的任务,并且 GPU 在多个进程间频繁切换也会消耗时间 。
对于 Python 场景下,比较理想的模式如下图所示:
 
微信 NLP 算法微服务治理

文章插图
 
 
通过多线程部署,并且去掉 GIL 的影响,这也正是 PyInter 的主要设计思路,将多个模型的副本放到多个线程中去执行,同时为每个 Python 任务创建一个单独的互相隔离的 Python 解释器,这样多个任务的 GIL 就不会互相干扰了 。这样做集合了多进程和多线程的优点,一方面 GIL 互相独立,另一方面本质上还是单进程多线程的模式,所以显存对象可以共享,也不存在 GPU 的进程切换开销 。
PyInter 实现的关键是进程内动态库的隔离,解释器的隔离,本质上是动态库的隔离,这里自研了动态库加载器,类似 dlopen,但支持“隔离”和“共享”两种动态库加载方式 。
 
微信 NLP 算法微服务治理

文章插图
 
以“隔离”方式加载动态库,会把动态库加载到不同的虚拟空间,不同的虚拟空间互相之间看不到 。以“共享”方式加载动态库,那么动态库可以在进程中任何地方看到和使用,包括各个虚拟空间内部 。
以“隔离”方式加载 Python 解释器相关的库,再以“共享”方式加载 cuda 相关的库,这样就实现了在隔离解释器的同时共享显存资源 。
四、微服务所面临的调度问题多个微服务起到同等的重要程度以及同样的作用,那么如何在多个微服务之间实现动态的负载均衡 。动态负载均衡很重要,但几乎不可能做到完美 。
为什么动态负载均衡很重要?原因有以下几点:
(1)机器硬件差异(CPU / GPU);
(2)Request 长度差异(翻译 2 个字 / 翻译 200 个字);
(3)Random 负载均衡下,长尾效应明显:
① P99/P50 差异可达 10 倍;
② P999/P50 差异可达 20 倍 。
(4)对微服务来说,长尾才是决定整体速度的关键 。
处理一个请求的耗时,变化比较大,算力区别、请求长度等都会影响耗时 。微服务数量增多,总会有一些微服务命中长尾部分,会影响整个系统的响应时间 。
为什么动态负载均衡难以完美?
方案一:所有机器跑一遍 Benchmark 。
这种方案不“动态”,无法应对 Request 长度的差异 。并且也不存在一个完美的 Benchmark 能反应性能,对于不同模型来说不同机器的反应都会不同 。


推荐阅读