摘要:本文整理自阿里巴巴高階開發工程師黃興勃 (斷塵) 在 Flink Forward Aisa 2021 核心技術專場的演講。主要內容包括:
- PyFlink 最新功能
- PyFlink Runtime
- 基於 FFI 的 PEMJA
- PyFlink Runtime 2.0
- Future Work
原 Flink Forward Asia 2021 演講中的 JCP 專案已改名為 PEMJA,並且於 2022 年 1 月 14 日正式開源,開源地址為:
https://github.com/alibaba/pemja
Ps: JCP 已在本文替換為 PEMJA。
一、PyFlink 新功能
PyFlink 1.14 新增了很多功能,主要分為功能、易用性和效能三個方面。
功能方面,新增了 State TTL config。在 1.14 以前已經實現了 Python Datastream API 以及一些操作 State 上的功能,但是並沒有提供 State TTL config 的配置,這也意味著使用者寫 Python Datastream API 的自定義函式時無法自動把State的值清掉,而是需要手動的操作,對使用者不夠友好。
易用性方面,主要新增了以下幾項功能:
- 在依賴管理部分支援了 tar.gz 格式。
- Profile 功能。使用者寫 PyFlink 會用到一些 Python 的自定義函式,但並不清楚這部分函式的效能瓶頸在哪裡。而有了 profile 功能之後,Python 函式出現效能瓶頸時,便可以通過 profile 分析它的瓶頸具體是由原因什麼引起,從而可以針對這部分進行一些優化。
- Print 功能。在 1.14 以前,列印自定義的 log 資訊必須使用 Python 自定義的 logging 模組。但對於 Python 使用者來說,print 是他們比較習慣使用的一種輸出日誌資訊的方式。所以在 1.14 新增上了這部分功能。
- Local Debug 模式。在 1.14 以前,使用者如果使用 Python 自定義的函式在本地開發 PyFlink 作業,必須使用 remote debug 方式除錯自定義邏輯,但它使用起來相對比較繁瑣,而且使用門檻較高。在 1.14 改變了這種模式,如果在本地編寫一個 PyFlink 作業使用了 Python 自定義函式,可以自動切到 local debug 模式,可以在 ide 裡面直接 debug 自定義 Python 函式。
效能方面主要新增了以下功能:
- Operator Fusion。這個功能主要針對在 Python Datastream API 的作業中做連續幾個運算元操作的場景。比如兩次 .map 操作,在 1.14 以前,這兩個 .map 會分別執行在兩個 Python worker 中,而實現了 Operator Fusion 後,它們會被 merge 並執行在同一個 operator 中,然後由 Python worker 執行總的結果,達到了很好的效能優化。
- State 序列化/反序列化優化。在 1.14 以前,State 序列化/反序列化優化是使用 Python 內建的序列化器 pickle,它能序列化各種 Python 自定義的資料結構,但需要把 State type 的資訊序列化到資料結構中,這會導致序列化的結構體會更大。1.14 中對其進行了優化,使用了自定義的序列化器,一個 type 對應一個序列化器來做優化,使得序列化資訊更小。
- Finish Bundle 優化。在 1.14 以前 Finish Bundle 是同步的操作,如今把它改成了非同步的操作,提高了它的效能,而且能解決一些 Checkpoint 無法完成的場景。
二、PyFlink Runtime
上圖是 PyFlink 現有的框架圖。
圖左側的最上方的 Python Table API & SQL 和 Datastream API 是提供給使用者的 Python API。使用者通過這兩個 Python API 編寫 PyFlink 作業,再通過一個 py4j 的三方庫把 Python API 轉換成 Java API,即可對應到 Flink Java API 來描述這個作業。
針對 Table 和 SQL 的作業有個額外的 optimizer,它有兩種 rule,一種是常見的 common rules,另一種是 Python rules。這裡為什麼會有 Python rules?眾所周知,common rules 針對各種 Table 和 SQL 現有的作業都是有效的,而 Python rules 做的優化是針對 PyFlink 作業中使用了自定義的 Python 函式的場景,能夠把對應的 operator 抽取出來。
描述完了作業之後,它會被翻譯成一個 jobgraph,裡面有對應的 Python operators。Python operators 描述的 jobgraph 會提交到 TM (Runtime) 上去執行, Runtime 中也有個 Python operators。
圖右側是 Python operators 的各種元件,描述了 PyFlink Runtime 最核心的部分。主要分為兩個部分:Java operator 和 Python worker。
Java operator 中它有很多個元件,包括 data service 和 State service,以及針對 checkpoint、watermark 和 State request 的一些處理。因為自定義 Python 函式無法直接執行在 Flink 現有的架構之上,Flink 現有的架構是基於 JVM 的,但是編寫 Python 函式需要一個 Python Runtime,所以用 operator worker 來解決這個問題。
解決方案如下:發起一個 Python 程式執行 Python 自定義的函式,同時使用 Java operator 處理上游來的資料,再經過特殊處理之後傳送給對應的 Python worker。這裡使用的是程式間通訊的方案,也就是圖中的 data service。State service 針對 Python Datastream API 對 State 的操作,通過在 Python 裡操作 State,資料會從 Python worker 返回到 Java operator,Java operator 再通過訪問 State backend 拿到對應的 State 資料,並回傳給 Python worker,最後使用者就可以操作 State 的結果了。
上圖是 PyFlink Runtime Workflow。裡面的角色分別是 Python operator、Python runner、bundle processor、coder、Python operation,這幾個不同的角色執行在不同的地方。其中 Python operator 和 Python runner 是執行在 Java JVM 裡,負責對接上游和下游的 Java operator,而 bundle processor、coder 以及 Python operation 執行在 PVM 裡,bundle processor 利用了現有的 Apache Bean 框架,能夠接收來自於 Java Python 的資料,它們之間使用了程式間通訊。coder 是在 Python 端的一個自定義的序列化器,Java 端傳送了一條資料,經過 Python operator 傳送給 Python runner,由 Python runner 進行序列化後,再通過程式間的通訊傳送給 bundle processor。bundle processor 再把序列化後的二進位制陣列通過 coder 將它反序列化並得到一個 Python 物件。最後通過 Python operation 把反序列化之後的 Python 引數作為一個函式體的入參,然後呼叫自定義的 Python 函式,得到自定義的結果。
上述流程的瓶頸主要存在以下幾個方面:首先是計算端呼叫使用者自定義函式以及在呼叫之前,存在框架層 Python 寫的開銷;其次是自定義序列化部分,在 Java 端和 Python 端都需要序列化和反序列化資料;第三部分是程式間的通訊。
針對上述瓶頸,進行了一些列優化:
- 計算方面,利用 codegen 將現有的 Python 調函式的變數全都改為常量,函式的執行效率會更高;另外,將現有的 Python operation 的實現全都改為 cython,相當於將 Python 轉化為 .c 的實現方式,效能得到了大幅提升;
- 序列化方面,提供了自定義序列化器,全都是純 c 的實現,比 Python 更高效。
- 通訊方面,目前暫未實現優化。
- 序列化和通訊的問題,本質上就是 Java 和 Python 互相呼叫的問題,也就是如何優化 PyFlink 的 Runtime 架構的問題。
三、基於 FFI 的 PEMJA
Java 和 Python 互相呼叫已經是一個比較通用的問題,目前也已經有很多種實現方案。
第一種是程式間互相呼叫的方案,即網路通訊的方案,包括以下幾種:
- socket 方案,它所有的通訊協議都是通過自己實現,可以很靈活,但是比較繁瑣;
- py4j 方案,即 PyFlink 和 PySpark 在客戶端編寫作業時都使用 py4j;
- Alink 方案,它是在 Runtime 執行時使用 py4j,也有自定義的 Python 函式;grpc 方案,它利用現有的 grp service,不需要自定義的協議,有自定義的 service 和 message;
- 此外,共享記憶體的方案也是另一種程式間通訊的方案,比如 Tensorflow on Flink,它是通過共享記憶體的方式實現的。還有 PyArrow Plasma,也是一種物件式的共享記憶體儲存。
上述方案都是針對程式間通訊,那麼能否讓 Python 和 Java 執行在同一個程式裡,從而完全消除程式間通訊帶來的困擾?
確實有一些現有的庫在這方面做了嘗試,第一種方案是將 Python 轉成 Java。比如 p2j 是把 Python 的 source code 轉成 Java 的 source code,voc 是把 Python 程式碼直接轉成 Java 的 bytecode,這種方案的本質就是將 Python 轉成一套可以直接執行在 JVM 之上的程式碼。但這套方案也存在不小的缺陷,因為 Python 是在不斷地發展,它有各種語法,而將 Python 語法對映到 Java 中對應的物件是很困難的,它們畢竟是不同的語言。
第二種方案是基於 Java 實現的 Python 直譯器。首先是 Jython 方案,Python 其實是用 c 語言寫的一套 Python 直譯器,c 寫的 Python 直譯器可以執行在 c 之上,那麼 Java 實現的 Python 直譯器也就可以直接執行在 JVM 之上。另外一種方案是 Graalvm,它提供了一種 truffle framework 的方式,可以支援各種程式語言使用共同的結構,這種結構能執行在 JVM 之上,也就可以讓各種語言執行在同一個程式裡。
上述方案實現的前提是能夠識別 Python code,也就意味著要能相容現有的各種 Python code,但是目前來看,相容是一個難以解決的問題,因此也就阻止了這套 Python 轉成 Java 方案繼續推廣的可能性。
第三種是基於 FFI 的一套方案。
FFI 的本質就是 host language 如何呼叫一個 guest language,即 Java 與 Python 之間的互相呼叫,對應的具體實現方案有很多種。
Java 提供了 JNI (Java native interface),讓 Java 使用者能夠通過 JNI 的介面呼叫 c 實現的一些 lib,反過來也同樣適用。有了這套介面之後, JVM 的廠商就會根據這套介面去實現 JNI,從而實現 Java 與 c 之間的互相呼叫。
Python/C API 也是類似的, Python 是一套 c 實現的直譯器,因此能很好地支援 Python 程式碼呼叫 c 的三方庫,反之也同樣適用。
Cython 提供了一個工具,能夠將 source code 轉換成另一種語言能識別的程式碼。比如將 Python 程式碼轉換成一套非常高效的 c 語言程式碼,再嵌入到 cPython 直譯器中即可直接執行,非常高效。
Ctypes 是通過將 c 的 library 封裝起來,使得 Python 能高效地呼叫 c 的 library。
上述提到基於 FFI 的方案的核心就是 c。有了 c 這個橋樑之後,一個 Java 寫成的程式碼,通過 JNI 介面就能呼叫到 c,然後由 c 去呼叫 cPython API 的介面,最終實現 Java 和 Python 執行在同一個執行緒裡,這就是 PEMJA 的整體思路。解決了程式間通訊的問題,以及因為它本身是使用的是自己提供的 Python/C API,也就不存在相容性的問題,克服了 Java 實現直譯器的缺陷。
上圖展示了基於這套思想的幾種實現,但這幾種實現都或多或少存在一些問題。
JPype 解決的問題是 Python 呼叫 Java 的問題,不支援 Java 呼叫 Python,所以它並不適用這個場景。
JEP 實現了 Java 呼叫 Python,但它的具體實現存在很多限制,一是隻能用原始碼安裝,對環境的要求非常高,以及它需要依賴 cPython 三方的一些 .source 檔案,非常不利於跨平臺的安裝使用。JEP 的啟動入口必須是JEP的程式,需要動態載入類庫,必須提前在環境變數中設定好,非常不利於它作為一個第三方的中介軟體外掛執行在另一個架構上。此外還有效能上問題,它沒有很好地克服現有的 Python GIL 的問題,所以導致它的效能並不是那麼高效。
而 PEMJA 基本克服了上述問題,更好的實現了 Java 和 Python 互相呼叫。
上圖是幾種框架的效能對比。這裡使用了一個比較標準簡單的 String upper 函式。這裡主要比較的是框架層的開銷,並不是自定義函式的效能,所以使用了一個最簡單的函式。同時,考慮到現有的各種函式最常用的資料結構是 String,所以這裡使用了 String。
這裡分別對比的是 100 個 bytes 和 1000 個 bytes 在這 4 種直譯器下的效能,可以看到 Jython 並沒有像想象中那麼高效,反而是這 4 種實現方案中效能最低的。JEP 的效能也遠遠比不上 PEMJA,PEMJA 在 100 bytes 的時候大概是純 Java 實現的 40%,1000 bytes 的情況下效能居然超越了純 Java 的實現。
如何解釋這個現象呢?String upper 本身是一套 Java 的實現,而在 Python 中它是 .c 的實現,函式本身的執行效率比 Java 高,再結合框架開銷足夠小的情況,整體的效能反而比 Java 更高,也就意味著在某些場景下,Python UDF 的效能是有可能超越 Java UDF 的。
現在很多使用者使用 Java UDF 而不使用 Python UDF 的一個關鍵點是 Python UDF 效能遠遠比不上 Java。但是如果 Java 的效能並沒有比 Python 更好的話,Python 反而就有了優勢,因為它畢竟是一種指令碼語言,寫起來是更方便。
上圖展示了 PEMJA 的架構。
Java 中的 damond thread 負責初始化以及最後的銷燬以及在 PEMJA 和對應的 Python PVM 裡建立及釋放資源。使用者使用的是 Java 中的 PEMJA 例項,例項對映到 PEMJA 中對應 PEMJA 的 instance,instant 會建立每一個 Python 的 sub interpreter。Python double interpreter 相對於全域性 Python interpreter,是一個更小的能夠掌控 GIL 的概念,它有自己獨立的 hip 空間,所以能夠實現名稱空間的隔離。這裡的每一個 thread 都會對應一個 Python sub interpret,可以在對應的 PVM 裡執行自己的 Python function。
四、PyFlink Runtime 2.0
PyFlink Runtime 2.0 就是基於 PEMJA 做的。
上圖左邊是 PyFlink 1.0 的架構。裡面有兩個程式,一個是 Java 程式,一個是 Python 程式。它們之間的資料互動是通過 data service 和 State service 實現,使用了程式 IPC 通訊。
有了 PEMJA 之後,就可以把 data service 和 State service 替換成 PEMJA Lib,隨即可以把左邊原來的 JVM 和右邊的 PVM 執行在同一個程式裡,從而徹底解掉的 IPC 程式通訊的問題。
上圖將現有的 PyFlink UDF、PyFlink 基於 PEMJA 的一套 UDF 以及 Java UDF 做了效能對比。也是使用 String upper 函式,比較 100 bytes 和 1000 bytes 的效能。可以看到,在 100 bytes 的情況下,UDF on PEMJA 的實現已經基本達到 Java UDF 的 50% 的效能。在 1000 bytes 的情況下,UDF on PEMJA 的效能已經超越了 Java UDF。雖然這和實現了自定義的函式有關,但也能說明這套 PEMJA 框架的效能之高效。
五 、Future Work
未來,會開源 PEMJA 框架 (已於 2022 年 1 月 14 日正式開源),因為它涉及到通用的解決方案,不僅僅是運用在 PyFlink 之上,各種 Java 和 Python 互相呼叫的方案也都可以利用這套框架,所以會對 PEMJA 框架做一個獨立的開源。它的第一個版本暫時只支援 Java 呼叫 Python 功能,後續會支援 Python 呼叫 Java 的功能,因為 Python Datastream API 用 Python 寫的函式呼叫 State 是依賴於 Python 呼叫 Java 的功能。此外,將實現 PEMJA 支援 Numpy 原生資料結構,實現了這個支援之後,pandas UDF 也就得以運用,效能將會得到質的飛躍。
歡迎大家加入 “PyFlink 交流群”,交流 PyFlink 相關的問題。
時間:5 月 21 日 9:00-12:25
PC 端直播觀看:https://developer.aliyun.com/...
移動端建議微信掃一掃關注 ApacheFlink 視訊號預約觀看:
更多 Flink 相關技術問題,可掃碼加入社群釘釘交流群
第一時間獲取最新技術文章和社群動態,請關注公眾號~
活動推薦
阿里雲基於 Apache Flink 構建的企業級產品-實時計算Flink版現開啟活動:
99 元試用 實時計算Flink版(包年包月、10CU)即有機會獲得 Flink 獨家定製衛衣;另包 3 個月及以上還有 85 折優惠!
瞭解活動詳情:https://www.aliyun.com/produc...