機器學習web服務化實戰(zhàn):一次吐血的服務化之路
背景
在公司內(nèi)部,我負責幫助研究院的小伙伴搭建機器學習web服務,研究院的小伙伴提供一個機器學習本地接口,我負責提供一個對外服務的HTTP接口。
說起人工智能和機器學習,python是最擅長的,其以開發(fā)速度快,第三方庫多而廣受歡迎,以至于現(xiàn)在大多數(shù)機器學習算法都是用python編寫。但是對于服務化來說,python有致命的問題:很難利用機器多核。由于一個python進程中全局只有一個解釋器,故多線程是假的,多個線程只能使用一個核,要想充分利用多核就必須使用多進程。此外由于機器學習是CPU密集型,其對多核的需求更為強烈,故要想服務化必須多進程。但是機器學習服務有一個典型特征:服務初始化時,有一個非常大的數(shù)據(jù)模型要加載到內(nèi)存,比如我現(xiàn)在要服務化的這個,模型加載到內(nèi)存需要整整8G的內(nèi)存,之后在模型上的分類、預測都是只讀,沒有寫操作。所以在多進程基礎上,也要考慮內(nèi)存限制,如果每個進程都初始化自己的模型,那么內(nèi)存使用量將隨著進程數(shù)增加而成倍上漲,如何使得多個進程共享一個內(nèi)存數(shù)據(jù)模型也是需要解決的問題,特別的如何在一個web服務上實現(xiàn)多進程共享大內(nèi)存模型是一個棘手的問題。
首先,我們來看看如何進行web服務化呢?我使用python中廣泛利用的web框架:Flask + gunicorn。Flask + gunicorn我這里面認為大伙都用過,所以我后面寫的就省略些,主要精力放在遇到的問題和解決問題的過程。
實現(xiàn)方式1:每個進程分別初始化自己的模型
為此我編寫了一個python文件來對一個分類模型進行服務化,文件首先進行模型初始化,之后每次web請求,對請求中的數(shù)據(jù)data利用模型進行預測,返回其對應的標簽。
#label_service.py
# 省略一些引入的包
model = Model() #數(shù)據(jù)模型
model.load() #模型加載訓練好的數(shù)據(jù)到內(nèi)存中
app = Flask(__name__)
class Label(MethodView):
def post(self):
data = request.data
label = model.predict(data)
return label
app.add_url_rule('/labelservice/', view_func=Label.as_view('label'), methods=['POST','GET'])
利用gunicorn進行啟動,gunicorn的好處在于其支持多進程,每個進程可以獨立的服務一個外部請求,這樣就可以利用多核。
gunicorn -w8 -b0.0.0.0:12711 label_service:app
其中:
-w8 意思是啟動8個服務進程。
滿心歡喜的啟動,但是隨即我就發(fā)現(xiàn)內(nèi)存直接爆掉。前面說過,我的模型加載到內(nèi)存中需要8個G,但是由于我啟動了8個工作進程,每個進程都初始化一次模型,這就要求我的機器至少有64G內(nèi)存,這無法忍受??墒牵绻揖烷_一個進程,那么我的多核機器的CPU就浪費了,怎么辦?
那么有沒有什么方法能夠使得8個工作進程共用一份內(nèi)存數(shù)據(jù)模型呢? 很遺憾,python中提供多進程之間共享內(nèi)存都是對于固定的原生數(shù)據(jù)類型,而我這里面是一個用戶自定義的類。此外,模型中依賴的大量的第三方機器學習包,這些包本身并不支持共享內(nèi)存方式,而且我也不可能去修改它們的源碼。怎么辦?
gunicorn 進程模型
仔細看了gunicorn的官方文檔,其中就有對其工作模型的描述。
- gunicorn主進程:負責fork子進程并監(jiān)控子進程,根據(jù)外部信號來決定是否增加或者減少子進程的數(shù)量。
- gunicorn子進程:負責接收web請求并且完成請求計算。
我突發(fā)奇想,我可以利用gunicorn父子進程在fork時共享父進程內(nèi)存空間直接使用模型,只要沒有對模型的寫操作,就不會觸發(fā)copy-on-write,內(nèi)存就不會由于子進程數(shù)量增加而成本增長。原理圖如下:

主進程首先初始化模型,之后fork的子進程直接就擁有父進程的地址空間。接下來的問題就是如何在gunicron的一個恰當?shù)牡胤竭M行初始化,并且如何把模型傳遞給Flask。
實現(xiàn)方式2:利用gunicorn配置文件只在主進程中初始化模型
查看gunicorn官方文檔,可以在配置文件配置主進程初始化所需的數(shù)據(jù),gunicorn保證配置文件中的數(shù)據(jù)只在主進程中初始化一次。之后可以利用gunicorn中的HOOK函數(shù)pre_request,把model傳遞給flask處理接口。
#gunicorn.conf
import sys
sys.path.append(".") #必須把本地路徑添加到path中,否則gunicorn找不到當前目錄所包含的類
model = Model()
model.load()
def pre_request(worker, req):
req.headers.append(('FLASK_MODEL', model)) #把模型通過request傳遞給flask。
pre_request = pre_request
#label_service.py
# 省略一些引入的包
app = Flask(__name__)
class Label(MethodView):
def post(self):
data = request.data
model = request.environ['HTTP_FLASK_MODEL'] #從這里取出模型,注意多了一個HTTP前綴。
label = model.predict(data)
return label
app.add_url_rule('/labelservice/', view_func=Label.as_view('label'), methods=['POST','GET'])
啟動服務:
gunicorn -c gunicorn.conf -w8 -b0.0.0.0:12711 label_service:app
使用 -c 指定我們的配置文件。
啟動服務發(fā)現(xiàn)達到了我的目的,模型只初始化一次,故總內(nèi)存消耗還是8G。
這里面提醒大家,當你用top看內(nèi)存時,發(fā)現(xiàn)每個子進程內(nèi)存大小還是8G,沒有關系,我們只要看本機總的剩余內(nèi)存是減少8G還是減少了8*8=64G。
到此,滿心歡喜,進行上線,但是悲劇馬上接踵而來。服務運行一段時間,每個進程內(nèi)存陡增1G,如下圖是我指定gunicorn進程數(shù)為1的時候,實測發(fā)現(xiàn),如果啟動8個gunicorn工作進程,則內(nèi)存在某一時刻增長8G,直接oom。

到此,我的內(nèi)心是崩潰的。不過根據(jù)經(jīng)驗我推測,在某個時刻某些東西觸發(fā)了copy-on-write機制,于是我讓研究院小伙伴仔細審查了一下他們的模型代碼,確認沒有寫操作,那么就只可能是gunicorn中有寫操作。
接下來我用蹩腳的英文在gunicorn中提了一個issue:https://github.com/benoitc/gunicorn/issues/1892 ,大神立刻給我指出了一條明路,原來是python的垃圾收集器搞的鬼,詳見:https://bugs.python.org/issue31558 , 因為python的垃圾收集會更改每個類的 PyGC_Head,從而它觸發(fā)了copy-on-write機制,導致我的服務內(nèi)存成倍增長。
那么有沒有什么方法能夠禁止垃圾收集器收集這些初始化好的需要大內(nèi)存的模型呢?有,那就是使用gc.freeze(), 詳見 https://docs.python.org/3.7/library/gc.html#gc.freeze 。但是這個接口在python3.7中才提供,為此我不得不把我的服務升級到python3.7。
實現(xiàn)方式3:python2.7升級到python3.7后使用gc.freeze()
升級python是一件非常痛苦的事情,因為我們的代碼都是基于python2.7編寫,許多語法在python3.7中不兼容,特別是字符串操作,簡直惡心到死,只能一一改正,除此之外還有pickle的不兼容等等,具體修改過程不贅述。最終我們的服務代碼如下。
#gunicorn.conf
import sys
import gc
sys.path.append(".") #必須把本地路徑添加到path中,否則gunicorn找不到當前目錄所包含的類
model = Model()
model.load()
gc.freeze() #調(diào)用gc.freeze()必須在fork子進程之前,在gunicorn的這個地方調(diào)用正好合適,freeze把截止到當前的所有對象放入持久化區(qū)域,不進行回收,從而model占用的內(nèi)存不會被copy-on-write。
def pre_request(worker, req):
req.headers.append(('FLASK_MODEL', model)) #把模型通過request傳遞給flask。
pre_request = pre_request
上線之后觀察到,我們單個進程內(nèi)存大小從8個G降低到6.5個G,這個推測和python3.7本身的優(yōu)化有關。其次,運行一段時間后,每個子進程內(nèi)存緩慢上漲500M左右后達到穩(wěn)定,這要比每個子進程突然增加1G內(nèi)存(并且不知道是否只突增一次)要好的多。
使用父子進程共享數(shù)據(jù)后需要進行預熱
當使用gunicorn多進程實現(xiàn)子進程與父進程共享模型數(shù)據(jù)后,發(fā)現(xiàn)了一個問題:就是每個子進程模型的第一次請求計算耗時特別長,之后的計算就會非???。這個現(xiàn)象在每個進程擁有自己的獨立的數(shù)據(jù)模型時是不存在的,不知道是否和python的某些機制有關,有哪位小伙伴了解可以留言給我。對于這種情況,解決辦法是在服務啟動后預熱,人為盡可能多發(fā)幾個預熱請求,這樣每個子進程都能夠進行第一次計算,請求處理完畢后再上線,這樣就避免線上調(diào)用方長時間hang住得不到響應。
結語
到此,我的服務化之路暫時告一段落。這個問題整整困擾我一周,雖然解決的不是很完美,但是對于我這個python新手來說,還是收獲頗豐。也希望我的這篇文章能夠對小伙伴們產(chǎn)生一些幫助。

浙公網(wǎng)安備 33010602011771號