如何調用一個只支持batch_call的服務?
我們先來說下標題是什么意思。
為了更好的理解我說的是啥,我們來舉個例子。
假設你現在在做一個類似B站的系統,里面放了各種視頻。
用戶每天在里頭上傳各種視頻。
按理說每個視頻都要去審查一下有沒有搞顏色,但總不能人眼挨個看吧。
畢竟唐老哥表示這玩意看多了,看太陽都是綠色的,所以會有專門訓練過的算法服務去做檢測。
但也不能上來就整個視頻每一幀都拿去做審查吧,所以會在每個視頻里根據時長和視頻類型隨機抽出好幾張圖片去做審查,比如視頻標簽是美女的,算法愛看,那多抽幾張。標簽是編程的,狗都不看,就少抽幾張。
將這些抽出來的圖片,送去審查。
為了實現這個功能,我們會以視頻為維度去做審核,而每個視頻里都會有N張數量不定的圖片,下游服務是個使用GPU去檢測圖片的算法服務。
現在問題來了,下游服務的算法開發告訴你,這些個下游服務,它不支持很高的并發,但請求傳參里給你加了個數組,你可以批量(batch)傳入一個比較大的圖片數組,通過這個方式可以提升點圖片處理量。
于是,我們的場景就變成。
上游服務的入參是一個視頻和它的N張圖片,出參是這個視頻是否審核通過。
下游服務的入參是N張圖片的,出參是這個視頻是否審核通過。
batch_call上下游
現在我們想要用上游服務接入下游服務。該怎么辦?
看上去挺好辦的,一把梭不就完事了嗎?
當一個視頻進來,就拿著視頻的十多張圖片作為一個batch去進行調用。
有幾個視頻進來,就開幾個這樣的并發。
這么做的結果就是,當并發大一點時,你會發現性能很差,并且性能非常不穩定,比如像下面的監控圖一樣一會3qps,一會15qps。處理的圖片也只支持20qps左右。
狗看了都得搖頭。
圖1-直接調用時qps很低
這可如何是好?
為什么下游需要batch call
本著先問是不是,再問為什么的精神,我們先看看為啥下游的要求會如此別致。
為什么同樣都是處理多張圖片,下游不搞成支持并發而要搞成批量調用(batch call)?
這個設定有點奇怪?
其實不奇怪,在算法服務中甚至很常見,舉個例子你就明白了。
同樣是處理多張圖片,為了簡單,我就假設是三張吧。如果是用單個cpu去處理的話。那不管是并發還是batch進來,由于cpu內部的計算單元有限,所以你可以簡單理解為,這三張圖片,就是串行去計算的。
cpu處理圖片時的流程
我計算第一張圖片是否能審核通過,跟第二張圖片是否能審核通過,這兩者沒有邏輯關聯,因此按道理兩張圖片是可以并行計算。
奈何我CPU計算單元有限啊,做不到啊。
但是。
如果我打破計算單元有限的這個條件,給CPU加入超多計算單元,并且弱化一些對于計算沒啥用處的組件,比如cache和控制單元。那我們就有足夠的算力可以讓這些圖片的計算并行起來了。
并行處理圖片
是的,把CPU這么一整,它其實就變成了GPU。
GPU和CPU的區別
上面的講解只是為了方便理解,實際上,gpu會以更細的粒度去做并發計算,比如可以細到圖片里的像素級別。
這也是為什么如果我們跑一些3d游戲的時候,需要用到顯卡,因為它可以快速的并行計算畫面里每個地方的光影,遠近效果啥的,然后渲染出畫面。
回到為什么要搞成batch call的問題中。
其實一次算法服務調用中,在數據真正進入GPU前,其實也使用了CPU做一些前置處理。
因此,我們可以簡單的將一次調用的時間理解成做了下面這些事情。
GPU處理圖片時的流程
服務由CPU邏輯和GPU處理邏輯組成,調用進入服務后,會有一些前置邏輯,它需要CPU來完成,然后才使用GPU去進行并行計算,將結果返回后又有一些后置的CPU處理邏輯。中間的GPU部分,管是計算1張圖,還是計算100張圖,只要算力支持,那它們都是并行計算的,耗時都差不多。
如果把這多張圖片拆開,并發去調用這個算法服務,那就有 N組這樣的CPU+GPU的消耗,而中間的并行計算,其實沒有利用到位。
并且還會多了前置和后置的CPU邏輯部分,算法服務一般都是python服務,主流的一些web框架幾乎都是以多進程,而不是多線程的方式去處理外部請求,這就有可能導致額外的進程間切換消耗。
當并發的請求多了,請求處理不過來,后邊來的請求就需要等前邊的處理完才能被處理,后面的請求耗時看起來就會變得特別大。這也是上面圖1里,接口延時(latency)像過山車那樣往上漲的原因。
還是上面的圖1的截圖,一張圖用兩次哈哈
按理說減少并發,增大每次調用時的圖片數量,就可以解決這個問題。
這就是推薦batch call的原因。
但問題又來了。
每次調用,上游服務輸入的是一個視頻以及它的幾張圖片,調用下游時,batch的數量按道理就只能是這幾張圖片的數量,怎么才能增大batch的數量呢?
這里的調用,就需要分為同步調用和異步調用了。
同步調用和異步調用的區別
同步調用,意思是上游發起請求后,阻塞等待,下游處理邏輯后返回結果給上游。常見的形式就像我們平時做的http調用一樣。
同步調用
異步調用,意思是上游發起請求后立馬返回,下游收到消息后慢慢處理,處理完之后再通過某個形式通知上游。常見的形式是使用消息隊列,也就是mq。將消息發給mq后,下游消費mq消息,觸發處理邏輯,然后再把處理結果發到mq,上游消費mq的結果。
異步調用
異步調用的形式接入
異步調用的實現方式
回到我們文章開頭提到的例子,當上游服務收到一個請求(一個視頻和它對應的圖片),這時候上游服務作為生產者將這個數據寫入到mq中,請求返回。然后新造一個C服務,負責批量消費mq里的消息。這時候服務C就可以根據下游服務的性能控制自己的消費速度,比如一次性消費10條數據(視頻),每個數據下面掛了10個圖片,那我一次batch的圖片數量就是10*10=100張,原來的10次請求就變為了1次請求。這對下游就相當的友好了。
下游返回結果后,服務C將結果寫入到mq的另外一個topic下,由上游去做消費,這樣就結束了整個調用流程。
當然上面的方案,如果你把mq換成數據庫,一樣是ok的,這時候服務C就可以不斷的定時輪詢數據庫表,看下哪些請求沒處理,把沒處理的請求批量撈出來再batch call下游。不管是mq還是數據庫,它們的作用無非就是作為中轉,暫存數據,讓服務C根據下游的消費能力,去消費這些數據。
這樣不管后續要加入多少個新服務,它們都可以在原來的基礎上做擴展,如果是mq,加topic,如果是數據庫,則加數據表,每個新服務都可以根據自己的消費能力去調整消費速度。
mq串聯多個不同性能的服務
其實對于這種上下游服務處理性能不一致的場景,最適合用的就是異步調用。而且涉及到的服務性能差距越大,服務個數越多,這個方案的優勢就越明顯。
同步調用的方式接入
雖然異步調用在這種場景下的優勢很明顯,但也有個缺點,就是它需要最上游的調用方能接受用異步的方式去消費結果。其實涉及到算法的服務調用鏈,都是比較耗時的,用異步接口非常合理。但合理歸合理,有些最上游他不一定聽你的,就是不能接受異步調用。
這就需要采用同步調用的方案,但怎么才能把同步接口改造得更適合這種調用場景,這也是這篇文章的重點。
限流
如果直接將請求打到下游算法服務,下游根本吃不消,因此首先需要做的就是給在上游調用下游的地方,加入一個速率限制(rate limit)。
這樣的組件一般也不需要你自己寫,幾乎任何一個語言里都會有現成的。
比如golang里可以用golang.org/x/time/rate庫,它其實是用令牌桶算法實現的限流器。如果不知道令牌桶是啥也沒關系,不影響理解。
限流器邏輯
當然,這個限制的是當前這個服務調用下游的qps,也就是所謂的單節點限流。如果是多個服務的話,網上也有不少現成的分布式限流框架。但是,還是那句話,夠用就好。
限流只能保證下游算法服務不被壓垮,并不能提升單次調用batch的圖片數量,有沒有什么辦法可以解決這個問題呢?
參考Nagle算法的做法
我們熟悉的TCP協議里,有個算法叫Nagle算法,設計它的目的,就是為了避免一次傳過少數據,提高數據包的有效數據負載。
當我們想要發送一些數據包時,數據包會被放入到一個緩沖區中,不立刻發送,那什么時候會發送呢?
數據包會在以下兩個情況被發送:
- 緩沖區的數據包長度達到某個長度(MSS)時。
- 或者等待超時(一般為200ms)。在超時之前,來的那么多個數據包,就是湊不齊MSS長度,現在超時了,不等了,立即發送。
這個思路就非常值得我們參考。我們完全可以自己在代碼層實現一波,實現也非常簡單。
1.我們定義一個帶鎖的全局隊列(鏈表)。
2.當上游服務輸入一個視頻和它對應的N張圖片時,就加鎖將這N張圖片數據和一個用來存放返回結果的結構體放入到全局隊列中。然后死循環讀這個結構體,直到它有結果。就有點像阻塞等待了。
3.同時在服務啟動時就起一個線程A專門用于收集這個全局隊列的圖片數據。線程A負責發起調用下游服務的請求,但只有在下面兩個情況下會發起請求
- 當收集的圖片數量達到xx張的時候
- 距離上次發起請求過了xx毫秒(超時)
4.調用下游結束后,再根據一開始傳入的數據,將調用結果拆開來,送回到剛剛提到的用于存放結果的結構體中。
5.第2步里的死循環因為存放返回結果的結構體,有值了,就可以跳出死循環,繼續執行后面的邏輯。
batch_call同步調用改造
這就像公交車站一樣,公交車站不可能每來一個顧客就發一輛公交車,當然是希望車里顧客越多越好。上游每來一個請求,就把請求里的圖片,也就是乘客,塞到公交車里,公交車要么到點發車(向下游服務發起請求),要么車滿了,也沒必要等了,直接發車。這樣就保證了每次發車的時候公交車里的顧客數量足夠多,發車的次數盡量少。
大體思路就跟上面一樣,如果是用go來實現的話,就會更加簡單。
比如第1步里的加鎖全局隊列可以改成有緩沖長度的channel。第2步里的"用來存放結果的結構體",也可以改成另一個無緩沖channel。執行 res := <-ch, 就可以做到阻塞等待的效果。
而核心的仿Nagle的代碼也大概長下面這樣。當然不看也沒關系,反正你已經知道思路了。
func CallAPI() error {
size := 100
// 這個數組用于收集視頻里的圖片,每個 IVideoInfo 下都有N張圖片
videoInfos := make([]IVideoInfo, 0, size)
// 設置一個200ms定時器
tick := time.NewTicker(200 * time.Microsecond)
defer tick.Stop()
// 死循環
for {
select {
// 由于定時器,每200ms,都會執行到這一行
case <-tick.C:
if len(videoInfos) > 0 {
// 200ms超時,去請求下游
limitStartFunc(videoInfos, true)
// 請求結束后把之前收集的數據清空,重新開始收集。
videoInfos = make([]IVideoInfo, 0, size)
}
// AddChan就是所謂的全局隊列
case videoInfo, ok := <-AddChan:
if !ok {
// 通道關閉時,如果還有數據沒有去發起請求,就請求一波下游服務
limitStartFunc(videoInfos, false)
videoInfos = make([]IVideoInfo, 0, size)
return nil
} else {
videoInfos = append(videoInfos, videoInfo)
if videoInfos 內的圖片滿足xx數量 {
limitStartFunc(videoInfos, false)
videoInfos = make([]IVideoInfo, 0, size)
// 重置定時器
tick.Reset(200 * time.Microsecond)
}
}
}
}
return nil
}
通過這一操作,上游每來一個請求,都會將視頻里的圖片收集起來,堆到一定張數的時候再統一請求,大大提升了每次batch call的圖片數量,同時也減少了調用下游服務的次數。真·一舉兩得。
優化的效果也比較明顯,上游服務支持的qps從原來不穩定的3q~15q變成穩定的90q。下游的接口耗時也變得穩定多了,從原來的過山車似的飆到15s變成穩定的500ms左右。處理的圖片的速度也從原來20qps提升到350qps。
到這里就已經大大超過業務需求的預期(40qps)了,夠用就好,多一個qps都是浪費。
可以了,下班吧。
總結
- 為了充分利用GPU并行計算的能力,不少算法服務會希望上游通過加大batch的同時減少并發的方式進行接口調用。
- 對于上下游性能差距明顯的服務,建議配合mq采用異步調用的方式將服務串聯起來。
- 如果非得使用同步調用的方式進行調用,建議模仿Nagle算法的形式,攢一批數據再發起請求,這樣既可以增大batch,同時減少并發,真·一舉兩得,親測有效。
最后
講了那么多可以提升性能的方式,現在需求來了,如果你資源充足,但時間不充足,那還是直接同步調用一把梭吧。
性能不夠?下游加機器,gpu卡,買!
然后下個季度再提起一個技術優化,性能提升xx%,cpu,gpu減少xx%。
有沒有聞到?
這是KPI的味道。