快手 Dragonfly 策略引擎的設計與應用
一、問題與挑戰
1、問題背景
從 2018 年開始,快手的整個業務呈現快速發展的狀態,團隊也在快速擴張中。在過去的五年中,DAU 從 1 億增長至 3.76 億。在 2021 年,快手的 DAU 已經超過了 3 億。主要推薦場景也從早期的發現頁、關注頁和同城頁等幾個主要頁面,擴展到了如今的上百個推薦場景,包括電商、直播、增長、海外及本地生活等等。
伴隨著業務的快速發展,研發團隊從幾十人擴大到了上千人。在這種背景下,業務方面產生了兩個主要訴求:第一個是希望快速搭建一個新的推薦場景;另一個是快速復制有效的策略。
2、問題
早期,為了滿足這兩個訴求,開發團隊選擇復制已有功能的架構代碼,以加速開發過程。然而,隨著場景數量的不斷增加,這種方式已難以接受。因此,從長遠來看,快手需要重新審視并優化現有的代碼架構,以確保其能夠適應未來的發展需求。
同時,這種做法還會增加整個架構的維護難度。在快手,架構和算法人員配比較低,導致架構工程師壓力較大。在線系統所有代碼都是用 C++ 語言編寫的,隨著算法團隊人數的增加,系統越來越復雜,對線下代碼質量的把控變得更加困難。經常因為代碼 bug 導致線上穩定性問題。
另外,工程團隊和算法團隊之間存在天然的目標差異。例如,算法團隊追求快速迭代和實驗上線,而工程團隊更看重系統維護成本。因此,快手需要平衡兩個團隊的需求和目標,以確保整個系統的穩定性和可維護性。
另一種嚴重的情況是,項目的整個算法和工程代碼寫在同一個工作空間中,導致代碼存在嚴重的耦合現象,就像 DNA 雙鏈結構,彼此相互支撐,又相互糾纏在一起。這就意味著雙方都可能對對方產生一些意外的影響。
在這樣一個模式下,工程團隊往往會陷入循環重構的怪圈。隨著系統迭代時間的不斷增長,整個系統的復雜度也逐漸提高。當達到一定閾值時,工程團隊需要投入大量人力進行系統的重構。重構完成后,系統的復雜度會降低到一定水平,但隨著時間的推移,經過一兩年的迭代,系統又會變得復雜起來,需要再次重構。這種周而復始的現象每 1~2 年就會發生一次。
每次進行這樣的大型重構,對工程團隊的消耗非常大,導致團隊成員很難集中精力去關注其它更有價值的架構升級。因此,如何打破這個循環重構的宿命就成為了一個急需解決的關鍵問題。
經過深入分析,團隊發現了問題的核心原因是業務代碼和架構代碼之間存在過度的耦合。為了解決這個問題,快手自主研發了一套策略引擎框架。下面將詳細介紹該框架是如何解決這些問題的。
二、Dragonfly 框架介紹
1、Dragonfly 是什么
Dragonfly 在定位上是一個面向搜廣推領域的通用圖引擎框架及其周邊工具所構建的開發生態。該系統為快手內部搜廣推服務提供了統一的基座引擎,同時為上層業務提供了靈活的流程編排能力。
在底層,內置了一些高效的數據模型,并提供了豐富的周邊工具。上圖展示了整個架構,最上層是各個搜廣推領域的策略引擎,下面是支持策略服務、召回服務、粗排精排服務的核心層,最底下是圖調度引擎。通過DSL算子編排,成功地將算法的開發模式從 C++ 為主轉變為以 Python 為主。
2、策略編排
上圖中展示了一段代碼示例,以 Dragonfly 編寫策略的方式呈現,非常直觀。代碼定義了一個 flow 流程對象,類似于工作流的概念。在這個 flow 中,提供了很多方法,每個方法背后都有一個相應的算子。通過腳本,可以很容易理解這個流程。前兩個方法是兩步召回,從索引和服務中做召回,之后進行去重、曝光、過濾等。再根據 dislike 特征進行過濾截斷。接著進入下一個階段,進行多樣性的打散,然后進行截斷并返回。
通過 Python 的 DSL 描述,算法同學可以不編寫 C++ 代碼,而是通過 Python 腳本簡單、直觀地描述一段策略流程,這個 DSL 腳本將被編譯成一個 Json 文件,交給線上的 C++ 服務運行。這樣既享受了 Python 編寫邏輯的便利性,也沒有犧牲線上的性能。
為了實現這個效果,我們做了兩個核心的抽象:流程抽象和數據抽象。
3、流程抽象
利用算子化的方法加上 DAG 來拆分和抽象整個業務功能為各個算子。這些算子包括一些通用的算子,如過濾、召回、模型預估等。這些算子基本上由架構編寫和維護,各個業務可以直接復用,無需重復編寫。
通過自定義算子來滿足一些通用算子無法滿足的定制化的業務邏輯。這些自定義算子可以由業務人員自行編寫,以實現高度靈活的需求。
通過使用先前展示的 Python DSL,開發人員可以輕松編排這些算子。并且,由于這個腳本本質上是一段 Python 代碼,因此可以在此基礎上利用 Python 自身語法能力實現更復雜的代碼拆分及模塊化管理等功能。
整個 DAG 構圖方式是基于數據驅動的隱式構圖,因此,所有的算子都可以做到全流程漂移。
如圖所示,假設我們有一個包含六個算子的 flow,其中 B、C、D 三個是異步的算子,它們分別有下游依賴 E 和 F。根據數據依賴關系和拓撲序,可以唯一地反推出一幅 DAG 圖,其中 B、C 是并行的關系,B、C、E 整體與 D 也是并行的關系。因此,整個流程的處理結構就像上圖中部所示。
通過這樣的構圖機制,理論上可以構建出任意復雜的業務邏輯和業務流程。這里提到的可全程漂移意味著,如果將各個方法隨意換位置,那么構圖的結構也會自動發生變化。
最后,整個流程的調度是一個全程無鎖的設計,以支持在線的高并發需求。
4、數據抽象
除了流程抽象,Dragonfly 具備的另一項重要抽象能力是對數據的抽象。它提供了一種高性能的數據結構,叫做 DataFrame 表結構,類似于大數據領域中列存表的概念。
從邏輯上看,DataFrame 表結構就像圖示的二維數據表,以 item 側的數據為例,每行代表一個 Item,每列代表其屬性或特征。Common 側的數據實際上也是類似的,但因為 common 的數據對于所有item都是共享的,所以作為底層使用一個特化單行的 DataFrame 結構去承載。通過這樣的 DataFrame 結構,可以比較容易地實現統一數據接口的能力。例如,Dragonfly 架構為上層提供的是一個簡單的鍵值化數據接口,如果一個 item 想訪問一個特征的值,只需要傳入 item 的 key(一般是 item _id) 和 like 這個名字,就能獲取到這個值。Schema Free 的特點確保了在線系統不需要因為添加新的特征或數據而頻繁重新編譯,這比 protobuf 更加易用和高效。
此外,Dragonfly 框架對結構進行了諸多性能優化,例如零拷貝技術,這種技術貫穿于索引數據以及DataFrame間的數據傳遞等各個方面。
Dragonfly 框架更高級的功能是對邏輯表的全面支持。在復雜的業務場景中,可能需要處理一張大型物理表,每個團隊只需要專注于其中的一部分流程。因此,可以基于底層物理表創建邏輯表,這一概念類似于數據庫中的視圖。與視圖的只讀不同,Dragonfly 架構創建的邏輯表具有可讀寫性,可以作為其他團隊劃分數據操作空間的參考。通過這種方式,整個團隊可以更清晰、更靈活地管理其數據操作空間。
通過統一數據接口,我們得以輕松地進行數據讀寫管控,可以輕易地梳理并監控在線數據的讀寫使用情況,包括是否存在不合規的數據使用。整個框架內置了安全保障機制,確保了數據的并發安全。
5、DSL 層提供高階抽象能力
前面介紹的是框架底層的核心能力,但用戶主要會感知到的是 DSL 這一層。Dragonfly 提供了一些更高階的抽象能力,如標準算子的封裝,用戶可以直接使用。對于用戶來說,同步或異步是無感的,只需要簡單地調用算子接口,無需關心底層是同步還是異步實現。
此外,Dragonfly 在底層提供了分支流程控制、數據并行計算等高階功能。如上圖展示了數據并行計算的示例,假設要計算某個分數,類型為 double,如果每個計算的分數需要一毫秒,那么 8 個串行計算就需要 8 毫秒。但是,實際上每個分數的計算都是獨立的,可以將其視為數據上的并行操作。通過框架提供的 @ parallel 裝飾器,可以指定參數將數據分片,每片包含 4 個 item,每個線程處理一片數據,實現并行操作,將 8 毫秒降低到 4 毫秒。原理上跟向量化加速是一樣的。
對于流程,Dragonfly 提供了@async裝飾器幫助將子流程異步化,還提供了模塊化組件幫助上層 DSL 構建更復雜的業務邏輯。與傳統的 C++ 代碼實現相比,使用 Dragonfly 的 DSL 只需要簡單地添加裝飾器即可實現諸多復雜的功能。
6、分層解耦
通過 Dragonfly Python DSL 我們將整個算法和架構的研發工作空間進行劃分和隔離,實現層次分明。在 DSL 之上是算法的工作空間,算法人員只需要編寫DSL編排算子并提交配置,而無需關心底層算子的實現。在 DSL 之下是架構的工作空間,架構人員只需要編寫算子,并提供二進制文件以運行配置。架構對于上層業務邏輯無需關心。這樣就實現了清晰的層次劃分,使得兩者之間不會產生強烈的耦合效應,避免了互相干擾的情況。
7、應用現狀
當前,Dragonfly 已經支撐了整個搜推廣領域的上千個在離線服務的運行,實現了覆蓋整個推薦在線核心鏈路的技術模式。如圖,應用范圍不僅覆蓋策略服務,還包括整個鏈路上的召回服務、粗排精排重排等服務。
通過采用這一套技術模式,實現了幾個重要的目標。首先,統一的技術模型實現了整個在線服務協議。這個技術模型也為我們提供了便利的監控條件,可以輕松監控整個鏈路每個服務的內部算子情況、CPU 消耗等系統資源指標。此外,一些底層優化和編譯器優化也可以通過一次開發,在所有服務中復用。
當所有服務都采用這一套模式時,全鏈路將呈現一個靈活的狀態。這意味著鏈路節點上的每個節點都可以靈活地切分和融合。如果某個節點的服務隨著業務迭代時間的延長而變得臃腫,導致單機資源無法承受,可以將其從大單體服務切分為兩個小單體服務。同樣,如果發現某些服務對單機的資源消耗過低,可以將上下游的兩個服務進行靈活融合,將其變為一個服務。這種靈活的架構可以像微架構一樣,使我們能夠靈活地調整整個鏈路的架構,包括新舊服務的遷移。根據遷移經驗,采用這一套技術模式可以使原有服務的 C++ 代碼量減少 50~80%,顯著降低了代碼的復雜性和線上穩定性安全風險。
三、生態建設
1、生態工具
為了讓業務團隊高效地使用這個框架,僅僅做好一個框架是遠遠不夠的。要讓用戶充分體驗到這個框架的優勢,需要構建一個龐大的生態系統工具。
上圖展示了目前提供的相關工具。這些工具覆蓋了整個策略研發的全流程,包括上線前的編碼輔助、功能調試,以及上線后的指標監控和報警分析。這套框架的最大優勢在于,所有這些生態工具都可以做到一次建設全業務復用。接下來介紹幾個重點工具。
2、Playground
這是網頁版調試工具,用戶可以在頁面上直接編寫 DSL,并通過網頁運行查看結果。通過這個網頁版工具,用戶可以實現零部署的在線編寫調試。秒級響應,用戶可以在 Python 代碼中簡單構造輸入輸出數據,并執行查看效果。用戶除了可以直接查看結果,也可以通過 Python print 查看流程中的特征數據,或查看底層 C++ 算子的 glog 日志。這樣可以幫助用戶方便地調試程序邏輯。
3、白盒化回查
針對已經上線的在線服務,如果出現不良情況需要調查,整個框架具備自動打點的功能。通過用戶 ID(uid),可以追蹤歷史記錄中某條請求的完整執行情況;可以了解到該請求經過了哪些算子,每個算子的耗時和輸出數據等詳細信息。通過這種方式,開發人員可以進行歷史追蹤,排查可能出現的問題。
4、可視化
通過這種方式,可以從上至下、由粗到細地展示業務流程,能夠更清晰、直觀地了解整個業務流程的概況。
5、代碼治理
許多開發團隊都面臨著算法代碼無限膨脹的問題,需要一個有效的預防機制。Dragonfly 框架可以自動監測在線運行的無用算子并進行召回,甚至可以識別無用的分支。這樣,系統可以定期生成報告,比如右側的按分支生成的報告。這個報告可以精確地定位到哪位作者在哪個文件、哪個函數中寫了哪個無用的分支,以及它已經有多少天沒有被使用過了。
有了這樣的報告,就可以直接定位到編寫無用代碼的人,將報告發送給相關作者,促使其進行深入剖析并刪除這些無用的代碼。這樣,可以有效防止代碼無限膨脹,避免給未來的系統精簡和重構帶來不必要的壓力和消耗。
四、規劃展望
展望整個框架,未來將在以下三個方面持續發力:
1、性能方面
正在進行中的工作是提供 numa-aware 的能力。新一代 CPU 架構正在轉向多 numa 的架構,這可能讓已有服務的性能未處于最佳狀態。為了充分發揮硬件性能,需要上層代碼能夠更深入地感知 CPU 架構。目前框架可以幫助上層算子更簡單的感知到 numa 架構的情況,并靈活控制內存分配策略,以實現更優的訪存性能。
此外還可以根據整個鏈路關系執行圖優化,以降低線上服務耗時,并將這種優化擴展到全鏈路。
2、管控力方面
得益于全鏈路統一基座引擎的支持,可以輕松實現全鏈路特征管理和數據血源追蹤。未來,系統將能夠自動檢測出哪些數據已無用并直接將其刪除。
同時我們將建設系統的自凈化能力,實現代碼治理的自動化。系統將能夠識別出無用的邏輯或低效代碼,并從代碼庫中定期自動刪除,從而持續保證系統的健壯性。
3、產品化方面
希望在未來加強整個生態工具鏈的建設,提供更智能的工具,用 AI 技術驅動更高效的研發流程。
系統也將致力于提供更完整的綜合解決方案,甚至提供 to B 的能力。
如果您對此領域感興趣并希望加入我們的行列,共同創造卓越的成果,請將簡歷發送至郵箱 fangjianbing@kuaishou.com。我們將一起致力于構建更強大、更智能的工具和服務。謝謝大家的參與和支持!
五、問答環節
Q1:Dragonfly 的自定義算子的表達能力是否會比較有限,或者說它是否總能翻譯成 C++,還是能夠覆蓋一些比較復雜的 C++ 的推薦邏輯。
A1:取決于對算子抽象的程度。因為這個算子并不是直接翻譯成 C++ 代碼,而是由 C++ 代碼組裝起來。比如要做一個過濾,需要首先把過濾相關的邏輯先抽象出來,有哪些是公用的,哪些是可以配置化的,抽象出一個過濾的核心邏輯,給大家復用,而并不是直接在 Python 里面寫一個很細致很具體的過濾代碼,然后把它翻譯過來。算子并不是細到代碼行級別的粒度。
Q2:自定義算子的拆分粒度是什么?在實際開發中是否屬于自定義算子,是工程側還是算法側的同學去決定這件事情。
A2:這件事一般會是算法側自己去開發,工程側一般會去開發一些通用性的算子。自定義算子這部分會存在一些現實的問題,比如不同算法同學的抽象能力以及代碼能力是不一樣的,他們對自定義算子的粒度把控以及設計可能是存在欠缺的,會導致自定義算子的復用性很有限。
Q3:第三個問題是關于控制流的,和 TensorFlow 的圖的本質區別是什么?控制流具體是如何實現的?
A3:概念上類似于 TensorFlow,也是數據驅動的構圖方式。TensorFlow 直接暴露成一個變量,通過變量的傳遞就能推斷出數據依賴關系。但數據在我們 DSL 中是一個隱藏的概念,比如文中的例子,類似 ctr、pctr 這樣的數據,它算是作為一個配置項,體現在某一個算子的配置里面。從代碼級別上來看,并沒有存在這么一個 pctr 的 python 變量去承接這個數據,而是隱藏在 DSL 之下的一個概念,因為數據關系非常復雜,很難用變量傳遞去表達清晰。這是與 TensorFlow 的一個區別。TensorFlow 是通過參數傳遞的方式進行數據傳遞,而我們的控制流是通過函數的配置,它的入參有一個叫 pctr 的字面量值,后續某一個算子有一個 pctr 的值作為它配置的輸入,這樣去判斷出它的前后依賴關系,所以整個邏輯也都是靠拓撲加數據依賴的方式去構圖。總之,原理上類似,但具體實現細節上不太一樣。
Q4:關于微服務劃分,Dragonfly 實現的文件是一個微服務,想了解一下 DSL 中的算子的組織和微服務的劃分有什么樣的關系?
A4:一個 DSL 實際上最終對應的是一個服務的配置,比如策略服務,最終會生成一個 Json 的配置,然后交給系統去部署運行起來,對應的下游的召回粗排精排重排,也分別有一個獨立的配置。等同于一個 DSL 對應一個線上的服務節點。如果要做節點拆分,實際上就是把 DSL 前半部分的方法調用摘出來,把它挪到另外一個 DSL 的文件里面。