在公司內部,我負責幫助研究院的小夥伴搭建機器學習web服務,研究院的小夥伴提供一個機器學習本地接口,我負責提供一個對外服務的HTTP接口。html
提及人工智能和機器學習,python是最擅長的,其以開發速度快,第三方庫多而廣受歡迎,以致於如今大多數機器學習算法都是用python編寫。可是對於服務化來講,python有致命的問題:很難利用機器多核。因爲一個python進程中全局只有一個解釋器,故多線程是假的,多個線程只能使用一個核,要想充分利用多核就必須使用多進程。此外因爲機器學習是CPU密集型,其對多核的需求更爲強烈,故要想服務化必須多進程。可是機器學習服務有一個典型特徵:服務初始化時,有一個很是大的數據模型要加載到內存,好比我如今要服務化的這個,模型加載到內存須要整整8G的內存,以後在模型上的分類、預測都是隻讀,沒有寫操做。因此在多進程基礎上,也要考慮內存限制,若是每一個進程都初始化本身的模型,那麼內存使用量將隨着進程數增長而成倍上漲,如何使得多個進程共享一個內存數據模型也是須要解決的問題,特別的如何在一個web服務上實現多進程共享大內存模型是一個棘手的問題。python
首先,咱們來看看如何進行web服務化呢?我使用python中普遍利用的web框架:Flask + gunicorn。Flask + gunicorn我這裏面認爲大夥都用過,因此我後面寫的就省略些,主要精力放在遇到的問題和解決問題的過程。git
爲此我編寫了一個python文件來對一個分類模型進行服務化,文件首先進行模型初始化,以後每次web請求,對請求中的數據data利用模型進行預測,返回其對應的標籤。github
#label_service.py # 省略一些引入的包 model = Model() #數據模型 model.load() #模型加載訓練好的數據到內存中 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的好處在於其支持多進程,每一個進程能夠獨立的服務一個外部請求,這樣就能夠利用多核。web
gunicorn -w8 -b0.0.0.0:12711 label_service:app
其中:
-w8 意思是啓動8個服務進程。算法
滿心歡喜的啓動,可是隨即我就發現內存直接爆掉。前面說過,個人模型加載到內存中須要8個G,可是因爲我啓動了8個工做進程,每一個進程都初始化一次模型,這就要求個人機器至少有64G內存,這沒法忍受。但是,若是我就開一個進程,那麼個人多核機器的CPU就浪費了,怎麼辦?flask
那麼有沒有什麼方法可以使得8個工做進程共用一分內存數據模型呢? 很遺憾,python中提供多進程之間共享內存都是對於固定的原生數據類型,而我這裏面是一個用戶自定義的類。此外,模型中依賴的大量的第三方機器學習包,這些包自己並不支持共享內存方式,並且我也不可能去修改它們的源碼。怎麼辦?多線程
仔細看了gunicorn的官方文檔,其中就有對其工做模型的描述。app
我突發奇想,我能夠利用gunicorn父子進程在fork時共享父進程內存空間直接使用模型,只要沒有對模型的寫操做,就不會觸發copy-on-write,內存就不會因爲子進程數量增長而成本增加。原理圖以下:
框架
主進程首先初始化模型,以後fork的子進程直接就擁有父進程的地址空間。接下來的問題就是如何在gunicron的一個恰當的地方進行初始化,而且如何把模型傳遞給Flask。
查看gunicorn官方文檔,能夠在配置文件配置主進程初始化所需的數據,gunicorn保證配置文件中的數據只在主進程中初始化一次。以後能夠利用gunicorn中的HOOK函數pre_request,把model傳遞給flask處理接口。
#gunicorn.conf import sys sys.path.append(".") #必須把本地路徑添加到path中,不然gunicorn找不到當前目錄所包含的類 model = Model()//Python開發學習交流:705673780 model.load()//針對1-3年經驗Python開發人員 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 指定咱們的配置文件。
啓動服務發現達到了個人目的,模型只初始化一次,故總內存消耗仍是8G。
這裏面提醒你們,當你用top看內存時,發現每一個子進程內存大小仍是8G,沒有關係,咱們只要看本機總的剩餘內存是減小8G仍是減小了8*8=64G。
到此,滿心歡喜,進行上線,可是悲劇立刻接踵而來。服務運行一段時間,每一個進程內存陡增1G,以下圖是我指定gunicorn進程數爲1的時候,實測發現,若是啓動8個gunicorn工做進程,則內存在某一時刻增加8G,直接oom。
到此,個人心裏是崩潰的。不過根據經驗我推測,在某個時刻某些東西觸發了copy-on-write機制,因而我讓研究院小夥伴仔細審查了一下他們的模型代碼,確認沒有寫操做,那麼就只多是gunicorn中有寫操做。
接下來我用蹩腳的英文在gunicorn中提了一個issue:https://github.com/benoitc/gunicorn/issues/1892 ,大神馬上給我指出了一條明路,原來是python的垃圾收集器搞的鬼,詳見:https://bugs.python.org/issue31558 , 由於python的垃圾收集會更改每一個類的 PyGC_Head,從而它觸發了copy-on-write機制,致使個人服務內存成倍增加。
那麼有沒有什麼方法可以禁止垃圾收集器收集這些初始化好的須要大內存的模型呢?有,那就是使用gc.freeze(), 詳見 https://docs.python.org/3.7/library/gc.html#gc.freeze 。可是這個接口在python3.7中才提供,爲此我不得不把個人服務升級到python3.7。
升級python是一件很是痛苦的事情,由於咱們的代碼都是基於python2.7編寫,許多語法在python3.7中不兼容,特別是字符串操做,簡直噁心到死,只能一一改正,除此以外還有pickle的不兼容等等,具體修改過程不贅述。最終咱們的服務代碼以下。
#gunicorn.conf import sys import gc sys.path.append(".") #必須把本地路徑添加到path中,不然gunicorn找不到當前目錄所包含的類 model = Model() model.load() gc.freeze() #調用gc.freeze()必須在fork子進程以前,在gunicorn的這個地方調用正好合適,freeze把截止到當前的全部對象放入持久化區域,不進行回收,從而model佔用的內存不會被copy-on-write。 def pre_request(worker, req): req.headers.append(('FLASK_MODEL', model)) #把模型經過request傳遞給flask。 pre_request = pre_request
上線以後觀察到,咱們單個進程內存大小從8個G下降到6.5個G,這個推測和python3.7自己的優化有關。其次,運行一段時間後,每一個子進程內存緩慢上漲500M左右後達到穩定,這要比每一個子進程忽然增長1G內存(而且不知道是否只突增一次)要好的多。
當使用gunicorn多進程實現子進程與父進程共享模型數據後,發現了一個問題:就是每一個子進程模型的第一次請求計算耗時特別長,以後的計算就會很是快。這個現象在每一個進程擁有本身的獨立的數據模型時是不存在的,不知道是否和python的某些機制有關,有哪位小夥伴瞭解能夠留言給我。對於這種狀況,解決辦法是在服務啓動後預熱,人爲儘量多發幾個預熱請求,這樣每一個子進程都可以進行第一次計算,請求處理完畢後再上線,這樣就避免線上調用方長時間hang住得不到響應。
到此,個人服務化之路暫時告一段落。這個問題整整困擾我一週,雖然解決的不是很完美,可是對於我這個python新手來講,仍是收穫頗豐。也但願個人這篇文章可以對小夥伴們產生一些幫助。