微信 NLP 算法微服務(wù)治理

一、概述
馬斯克收購了推特,但對(duì)其技術(shù)表示不滿。認(rèn)為主頁速度過慢是因?yàn)橛?1000 多個(gè) RPC。先不評(píng)價(jià)馬斯克所說的原因是否正確,但可以看出,互聯(lián)網(wǎng)上為用戶提供的一個(gè)完整的服務(wù),背后會(huì)有大量的微服務(wù)調(diào)用。

以微信讀書推薦為例,分為召回和排序兩個(gè)階段。

請(qǐng)求到達(dá)后,會(huì)先從用戶特征微服務(wù)拉取特征,把特征組合在一起進(jìn)行特征篩選,然后調(diào)用召回相關(guān)的微服務(wù),這一流程還需要乘以一個(gè) N,因?yàn)槲覀兪嵌嗦氛倩?,?huì)有很多類似的召回流程在同時(shí)運(yùn)行。下面的是排序階段,從多個(gè)特征微服務(wù)中拉取相關(guān)特征,組合后多次調(diào)用排序模型服務(wù)。獲得最終結(jié)果后,一方面將最終結(jié)果返回給調(diào)用方,另一方面還要將流程的一些日志發(fā)送給日志系統(tǒng)留檔。
讀書推薦只是微信讀書整個(gè) APP 中非常小的一部分,由此可見,即便是一個(gè)比較小的服務(wù)后面也會(huì)有大量的微服務(wù)調(diào)用。管中窺豹,可以意料到整個(gè)微信讀書的系統(tǒng)會(huì)有巨量的微服務(wù)調(diào)用。
大量的微服務(wù)帶來了什么問題?

根據(jù)日常工作的總結(jié),主要是有以上三方面的挑戰(zhàn):
① 管理方面:主要是圍繞如何高效地管理、開發(fā)以及部署大量的算法微服務(wù)。
② 性能方面:要盡量提升微服務(wù),特別是算法微服務(wù)的性能。
③ 調(diào)度方面:如何在多個(gè)同類算法微服務(wù)之間實(shí)現(xiàn)高效合理的負(fù)載均衡。
二、微服務(wù)所面臨的管理問題
1、開發(fā)和部署:CI/CD 系統(tǒng)提供自動(dòng)打包和部署
第一點(diǎn)是我們提供了一些自動(dòng)打包和部署的流水線,減輕算法同學(xué)開發(fā)算法微服務(wù)的壓力,現(xiàn)在算法同學(xué)只需要寫一個(gè) Python 函數(shù),流水線會(huì)自動(dòng)拉取預(yù)先寫好的一系列微服務(wù)模板,并將算法同學(xué)開發(fā)的函數(shù)填入,快速搭建微服務(wù)。
2、擴(kuò)縮容:任務(wù)積壓感知自動(dòng)擴(kuò)縮容
第二點(diǎn)是關(guān)于微服務(wù)的自動(dòng)擴(kuò)縮容,我們采取的是任務(wù)積壓感知的方案。我們會(huì)主動(dòng)去探測(cè)某一類任務(wù)積壓或空閑的程度,當(dāng)積壓超過某一閾值后就會(huì)自動(dòng)觸發(fā)擴(kuò)容操作;當(dāng)空閑達(dá)到某一閾值后,也會(huì)去觸發(fā)縮減微服務(wù)的進(jìn)程數(shù)。
3、微服務(wù)組織:圖靈完備 DAG / DSL / 自動(dòng)壓測(cè) / 自動(dòng)部署
第三點(diǎn)是如何把大量的微服務(wù)組織在一起,來構(gòu)造出完整的上層服務(wù)。我們的上層服務(wù)是用 DAG 去表示的,DAG 的每一個(gè)節(jié)點(diǎn)代表一個(gè)對(duì)微服務(wù)的調(diào)用,每一條邊代表服務(wù)間數(shù)據(jù)的傳遞。針對(duì) DAG,還專門開發(fā)了 DSL(領(lǐng)域特定語言),更好地描述和構(gòu)造 DAG。并且我們圍繞 DSL 開發(fā)了一系列基于網(wǎng)頁的工具,可以直接在瀏覽器里進(jìn)行上層服務(wù)的可視化構(gòu)建、壓測(cè)和部署。
4、性能監(jiān)控:Trace 系統(tǒng)
第四點(diǎn)性能監(jiān)控,當(dāng)上層服務(wù)出現(xiàn)問題時(shí)要去定位問題,我們構(gòu)建了一套自己的 Trace 系統(tǒng)。針對(duì)每一個(gè)外來請(qǐng)求,都有一整套的追蹤,可以查看請(qǐng)求在每一個(gè)微服務(wù)的耗時(shí),從而發(fā)現(xiàn)系統(tǒng)的性能瓶頸。
三、微服務(wù)所面臨的性能問題
一般來說,算法的性能耗時(shí)都在深度學(xué)習(xí)模型上,優(yōu)化算法微服務(wù)的性能很大一部分著力點(diǎn)就在優(yōu)化深度學(xué)習(xí)模型 infer 性能??梢赃x擇專用的 infer 框架,或嘗試深度學(xué)習(xí)編譯器,Kernel 優(yōu)化等等方法,對(duì)于這些方案,我們認(rèn)為并不是完全有必要。在很多情況下,我們直接用 Python 腳本上線,一樣可以達(dá)到比肩 C++ 的性能。
不是完全有必要的原因在于,這些方案確實(shí)能帶來比較好的性能,但是性能好不是服務(wù)唯一的要求。有一個(gè)很著名的二八定律,以人與資源來描述,就是 20% 的人會(huì)產(chǎn)生 80% 的資源,換句話說,20% 的人會(huì)提供 80% 的貢獻(xiàn)。對(duì)于微服務(wù)來說,也是適用的。
我們可以把微服務(wù)分為兩類,首先,成熟穩(wěn)定的服務(wù),數(shù)量不多,可能只占有 20%,但是承擔(dān)了 80% 的流量。另一類是一些實(shí)驗(yàn)性的或者還在開發(fā)迭代中的服務(wù),數(shù)量很多,占了 80%,但是承擔(dān)的流量卻只占用的 20%,很重要的一點(diǎn)是,經(jīng)常會(huì)有變更和迭代,因此對(duì)快速開發(fā)和上線也會(huì)有比較強(qiáng)的需求。
前面提到的方法,比如 Infer 框架,Kernel 優(yōu)化等,不可避免的需要額外消耗開發(fā)成本。成熟穩(wěn)定的服務(wù)還是很適合這類方法,因?yàn)樽兏容^少,做一次優(yōu)化能持續(xù)使用很久。另一方面,這些服務(wù)承擔(dān)的流量很大,可能一點(diǎn)點(diǎn)的性能提升,就能帶來巨大的影響,所以值得去投入成本。
但這些方法對(duì)于實(shí)驗(yàn)性服務(wù)就不那么合適了,因?yàn)閷?shí)驗(yàn)性服務(wù)會(huì)頻繁更新,我們無法對(duì)每一個(gè)新模型都去做新的優(yōu)化。針對(duì)實(shí)驗(yàn)性服務(wù),我們針對(duì) GPU 混合部署場(chǎng)景,自研了 Python 解釋器 —— PyInter。實(shí)現(xiàn)了不用修改任何代碼,直接用 Python 腳本上線,同時(shí)可以獲得接近甚至超過 C++ 的性能。

我們以 Huggingface 的 bert-base 為標(biāo)準(zhǔn),上圖的橫軸是并發(fā)進(jìn)程數(shù),表示我們部署的模型副本的數(shù)量,可以看出我們的 PyInter 在模型副本數(shù)較多的情況下 QPS 甚至超越了 onnxruntime。

通過上圖,可以看到 PyInter 在模型副本數(shù)較多的情況下相對(duì)于多進(jìn)程和 ONNXRuntime 降低了差不多 80% 的顯存占用,而且大家注意,不管模型的副本數(shù)是多少,PyInter 的顯存占用數(shù)是維持不變的。
我們回到之前比較基礎(chǔ)的問題:Python 真的慢嗎?
沒錯(cuò),Python 是真的慢,但是 Python 做科學(xué)計(jì)算并不慢,因?yàn)檎嬲鲇?jì)算的地方并非 Python,而是調(diào)用 MKL 或者 cuBLAS 這種專用的計(jì)算庫。
那么 Python 的性能瓶頸主要在哪呢?主要在于多線程下的 GIL(Global Interpreter Lock),導(dǎo)致多線程下同一時(shí)間只能有一個(gè)線程處于工作狀態(tài)。這種形式的多線程對(duì)于 IO 密集型任務(wù)可能是有幫助的,但對(duì)于模型部署這種計(jì)算密集型的任務(wù)來說是毫無意義的。

那是不是換成多進(jìn)程,就能解決問題呢?

其實(shí)不是,多進(jìn)程確實(shí)可以解決 GIL 的問題,但也會(huì)帶來其它新的問題。首先,多進(jìn)程之間很難共享 CUDA Context/model,會(huì)造成很大的顯存浪費(fèi),這樣的話,在一張顯卡上部署不了幾個(gè)模型。第二個(gè)是 GPU 的問題,GPU 在同一時(shí)間只能執(zhí)行一個(gè)進(jìn)程的任務(wù),并且 GPU 在多個(gè)進(jìn)程間頻繁切換也會(huì)消耗時(shí)間。
對(duì)于 Python 場(chǎng)景下,比較理想的模式如下圖所示:

通過多線程部署,并且去掉 GIL 的影響,這也正是 PyInter 的主要設(shè)計(jì)思路,將多個(gè)模型的副本放到多個(gè)線程中去執(zhí)行,同時(shí)為每個(gè) Python 任務(wù)創(chuàng)建一個(gè)單獨(dú)的互相隔離的 Python 解釋器,這樣多個(gè)任務(wù)的 GIL 就不會(huì)互相干擾了。這樣做集合了多進(jìn)程和多線程的優(yōu)點(diǎn),一方面 GIL 互相獨(dú)立,另一方面本質(zhì)上還是單進(jìn)程多線程的模式,所以顯存對(duì)象可以共享,也不存在 GPU 的進(jìn)程切換開銷。
PyInter 實(shí)現(xiàn)的關(guān)鍵是進(jìn)程內(nèi)動(dòng)態(tài)庫的隔離,解釋器的隔離,本質(zhì)上是動(dòng)態(tài)庫的隔離,這里自研了動(dòng)態(tài)庫加載器,類似 dlopen,但支持“隔離”和“共享”兩種動(dòng)態(tài)庫加載方式。

以“隔離”方式加載動(dòng)態(tài)庫,會(huì)把動(dòng)態(tài)庫加載到不同的虛擬空間,不同的虛擬空間互相之間看不到。以“共享”方式加載動(dòng)態(tài)庫,那么動(dòng)態(tài)庫可以在進(jìn)程中任何地方看到和使用,包括各個(gè)虛擬空間內(nèi)部。
以“隔離”方式加載 Python 解釋器相關(guān)的庫,再以“共享”方式加載 cuda 相關(guān)的庫,這樣就實(shí)現(xiàn)了在隔離解釋器的同時(shí)共享顯存資源。
四、微服務(wù)所面臨的調(diào)度問題
多個(gè)微服務(wù)起到同等的重要程度以及同樣的作用,那么如何在多個(gè)微服務(wù)之間實(shí)現(xiàn)動(dòng)態(tài)的負(fù)載均衡。動(dòng)態(tài)負(fù)載均衡很重要,但幾乎不可能做到完美。
為什么動(dòng)態(tài)負(fù)載均衡很重要?原因有以下幾點(diǎn):
(1)機(jī)器硬件差異(CPU / GPU);
(2)Request 長度差異(翻譯 2 個(gè)字 / 翻譯 200 個(gè)字);
(3)Random 負(fù)載均衡下,長尾效應(yīng)明顯:
① P99/P50 差異可達(dá) 10 倍;
② P999/P50 差異可達(dá) 20 倍。
(4)對(duì)微服務(wù)來說,長尾才是決定整體速度的關(guān)鍵。
處理一個(gè)請(qǐng)求的耗時(shí),變化比較大,算力區(qū)別、請(qǐng)求長度等都會(huì)影響耗時(shí)。微服務(wù)數(shù)量增多,總會(huì)有一些微服務(wù)命中長尾部分,會(huì)影響整個(gè)系統(tǒng)的響應(yīng)時(shí)間。
為什么動(dòng)態(tài)負(fù)載均衡難以完美?
方案一:所有機(jī)器跑一遍 Benchmark。
這種方案不“動(dòng)態(tài)”,無法應(yīng)對(duì) Request 長度的差異。并且也不存在一個(gè)完美的 Benchmark 能反應(yīng)性能,對(duì)于不同模型來說不同機(jī)器的反應(yīng)都會(huì)不同。
方案二:實(shí)時(shí)獲取每一臺(tái)機(jī)器的狀態(tài),把任務(wù)發(fā)給負(fù)載最輕的。
這一方案比較直觀,但問題在于在分布式系統(tǒng)中沒有真正的“實(shí)時(shí)”,信息從一臺(tái)機(jī)器傳遞到另一臺(tái)機(jī)器一定會(huì)花費(fèi)時(shí)間,而在這一時(shí)間中,機(jī)器狀態(tài)就可以發(fā)生了改變。比如在某一瞬間,某一臺(tái) Worker 機(jī)器是最空閑的,多臺(tái)負(fù)責(zé)任務(wù)分發(fā)的 Master 機(jī)器都感知到了,于是都把任務(wù)分配給這臺(tái)最空閑的 Worker,這臺(tái)最空閑的 Worker 瞬間變成了最忙的,這就是負(fù)載均衡中著名的潮汐效應(yīng)。
方案三:維護(hù)一個(gè)全局唯一的任務(wù)隊(duì)列,所有負(fù)責(zé)任務(wù)分發(fā)的 Master 都把任務(wù)發(fā)送到隊(duì)列中,所有 Worker 都從隊(duì)列中取任務(wù)。
這一方案中,任務(wù)隊(duì)列本身就可能成為一個(gè)單點(diǎn)瓶頸,難以橫向擴(kuò)展。
動(dòng)態(tài)負(fù)載均衡難以完美的根本原因是信息的傳遞需要時(shí)間,當(dāng)一個(gè)狀態(tài)被觀測(cè)到后,這個(gè)狀態(tài)一定已經(jīng)“過去”了。Youtube 上有一個(gè)視頻,推薦給大家,“Load Balancing is Impossible” https://www.youtube.com/watch?v=kpvbOzHUakA。
關(guān)于動(dòng)態(tài)負(fù)載均衡算法,Power of 2 Choices 算法是隨機(jī)選擇兩個(gè) worker,將任務(wù)分配給更空閑的那個(gè)。這個(gè)算法是我們目前使用的動(dòng)態(tài)均衡算法的基礎(chǔ)。但是 Power of 2 Choices 算法存在兩大問題:首先,每次分配任務(wù)之前都需要去查詢下 Worker 的空閑狀態(tài),多了一次 RTT;另外,有可能隨機(jī)選擇的兩個(gè) worker 剛好都很忙。為了解決這些問題,我們進(jìn)行了改進(jìn)。

改進(jìn)后的算法是 Joint-Idle-Queue。

我們?cè)?Master 機(jī)器上增加了兩個(gè)部件,Idle-Queue 和 Amnesia。Idle-Queue 用來記錄目前有哪些 Worker 處于空閑狀態(tài)。Amnesia 記錄在最近一段時(shí)間內(nèi)有哪些 Worker 給自己發(fā)送過心跳包,如果某個(gè) Worker 長期沒有發(fā)送過心跳包,那么 Amnesia 就會(huì)逐漸將其遺忘掉。每一個(gè) Worker 周期性上報(bào)自己是否空閑,空閑的 Worker 選擇一個(gè) Master 上報(bào)自己的 IdIeness,并且報(bào)告自己可以處理的數(shù)量。Worker 在選擇 Master 時(shí)也是用到 Power of 2 Choices 算法,對(duì)其他的 Master,Worker 上報(bào)心跳包。
有新的任務(wù)到達(dá)時(shí),Master 從 Idle-Queue 里隨機(jī) pick 兩個(gè),選擇歷史 latency 更低的。如果 Idle-Queue 是空的,就會(huì)去看 Amnesia。從 Amnesia 中隨機(jī) pick 兩個(gè),選擇歷史 latency 更低的。
在實(shí)際的效果上,采用該算法,可以把 P99/P50 壓縮到 1.5 倍,相比 Random 算法有 10 倍的提升。
五、總結(jié)
在模型服務(wù)化的實(shí)踐中,我們遇到了三個(gè)方面的挑戰(zhàn):
首先是對(duì)于大量的微服務(wù),如何進(jìn)行管理,如何優(yōu)化開發(fā)、上線和部署的流程,我們的解決方案是盡量自動(dòng)化,抽取重復(fù)流程,將其做成自動(dòng)化流水線和程序。
第二是模型性能優(yōu)化方面,如何讓深度學(xué)習(xí)模型微服務(wù)運(yùn)行得更加高效,我們的解決方案是從模型的實(shí)際需求出發(fā),對(duì)于比較穩(wěn)定、流量較大的服務(wù)進(jìn)行定制化的優(yōu)化,對(duì)于實(shí)驗(yàn)型的服務(wù)采用 PyInter,直接用 Python 腳本上線服務(wù),也能達(dá)到 C++ 的性能。
第三是任務(wù)調(diào)度問題,如何實(shí)現(xiàn)動(dòng)態(tài)負(fù)載均衡,我們的解決方案是在 Power of 2 Choices 的基礎(chǔ)上,開發(fā)了 JIQ 算法,大幅緩解了服務(wù)耗時(shí)的長尾問題。















 
 
 











 
 
 
 