機器學習web服務化實戰:一次吐血的服務化之路

haolujun發表於2018-10-15

背景

在公司內部,我負責幫助研究院的小夥伴搭建機器學習web服務,研究院的小夥伴提供一個機器學習本地介面,我負責提供一個對外服務的HTTP介面。

說起人工智慧和機器學習,python是最擅長的,其以開發速度快,第三方庫多而廣受歡迎,以至於現在大多數機器學習演算法都是用python編寫。但是對於服務化來說,python有致命的問題:很難利用機器多核。由於一個python程式中全域性只有一個直譯器,故多執行緒是假的,多個執行緒只能使用一個核,要想充分利用多核就必須使用多程式。此外由於機器學習是CPU密集型,其對多核的需求更為強烈,故要想服務化必須多程式。但是機器學習服務有一個典型特徵:服務初始化時,有一個非常大的資料模型要載入到記憶體,比如我現在要服務化的這個,模型載入到記憶體需要整整8G的記憶體,之後在模型上的分類、預測都是隻讀,沒有寫操作。所以在多程式基礎上,也要考慮記憶體限制,如果每個程式都初始化自己的模型,那麼記憶體使用量將隨著程式數增加而成倍上漲,如何使得多個程式共享一個記憶體資料模型也是需要解決的問題,特別的如何在一個web服務上實現多程式共享大記憶體模型是一個棘手的問題。

首先,我們來看看如何進行web服務化呢?我使用python中廣泛利用的web框架:Flask + gunicorn。Flask + gunicorn我這裡面認為大夥都用過,所以我後面寫的就省略些,主要精力放在遇到的問題和解決問題的過程。

實現方式1:每個程式分別初始化自己的模型

為此我編寫了一個python檔案來對一個分類模型進行服務化,檔案首先進行模型初始化,之後每次web請求,對請求中的資料data利用模型進行預測,返回其對應的標籤。

#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的好處在於其支援多程式,每個程式可以獨立的服務一個外部請求,這樣就可以利用多核。

gunicorn  -w8 -b0.0.0.0:12711 label_service:app

其中:
-w8 意思是啟動8個服務程式。

滿心歡喜的啟動,但是隨即我就發現記憶體直接爆掉。前面說過,我的模型載入到記憶體中需要8個G,但是由於我啟動了8個工作程式,每個程式都初始化一次模型,這就要求我的機器至少有64G記憶體,這無法忍受。可是,如果我就開一個程式,那麼我的多核機器的CPU就浪費了,怎麼辦?

那麼有沒有什麼方法能夠使得8個工作程式共用一份記憶體資料模型呢? 很遺憾,python中提供多程式之間共享記憶體都是對於固定的原生資料型別,而我這裡面是一個使用者自定義的類。此外,模型中依賴的大量的第三方機器學習包,這些包本身並不支援共享記憶體方式,而且我也不可能去修改它們的原始碼。怎麼辦?

gunicorn 程式模型

仔細看了gunicorn的官方文件,其中就有對其工作模型的描述。

  • gunicorn主程式:負責fork子程式並監控子程式,根據外部訊號來決定是否增加或者減少子程式的數量。
  • gunicorn子程式:負責接收web請求並且完成請求計算。

我突發奇想,我可以利用gunicorn父子程式在fork時共享父程式記憶體空間直接使用模型,只要沒有對模型的寫操作,就不會觸發copy-on-write,記憶體就不會由於子程式數量增加而成本增長。原理圖如下:
機器學習web服務化實戰:一次吐血的服務化之路

主程式首先初始化模型,之後fork的子程式直接就擁有父程式的地址空間。接下來的問題就是如何在gunicron的一個恰當的地方進行初始化,並且如何把模型傳遞給Flask。

實現方式2:利用gunicorn配置檔案只在主程式中初始化模型

檢視gunicorn官方文件,可以在配置檔案配置主程式初始化所需的資料,gunicorn保證配置檔案中的資料只在主程式中初始化一次。之後可以利用gunicorn中的HOOK函式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 指定我們的配置檔案。

啟動服務發現達到了我的目的,模型只初始化一次,故總記憶體消耗還是8G。

這裡面提醒大家,當你用top看記憶體時,發現每個子程式記憶體大小還是8G,沒有關係,我們只要看本機總的剩餘記憶體是減少8G還是減少了8*8=64G。

到此,滿心歡喜,進行上線,但是悲劇馬上接踵而來。服務執行一段時間,每個程式記憶體陡增1G,如下圖是我指定gunicorn程式數為1的時候,實測發現,如果啟動8個gunicorn工作程式,則記憶體在某一時刻增長8G,直接oom。

機器學習web服務化實戰:一次吐血的服務化之路

到此,我的內心是崩潰的。不過根據經驗我推測,在某個時刻某些東西觸發了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。

實現方式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() #呼叫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新手來說,還是收穫頗豐。也希望我的這篇文章能夠對小夥伴們產生一些幫助。

相關文章