Python c/c++ 整合

Bush Yang
12 min readFeb 24, 2021

--

在 python 的開發場景,有時因爲效能或是其他因素,需要引用 c/c++ library ,本文研究幾個常用的方案:Python/C API、ctypes、cffi、pybind11、Cython,比較其中的優缺,以及應用場景,提供一個整合 python 和 C/C++ 的方案指南。主要討論是從 python 呼叫 c/c++ 的方向做討論,但從 python 呼叫 c++ 也是一大應用方向,本文在其中一章節會做探討。最後另外提出 RPC 的方案,作爲另一種 c/c++ python 的整合方式,並跑簡單的數據比較其中的效能差異,做一些探討。結論是沒有一個絕對完美適用任何情境的方案,每個方案都有各自的優劣,視專案需求和開發資源採用,文末有比較表供快速查閲

Python 呼叫 C/C++ 函式

Python/C API

Python/C API 是 python 原生支持的 API 用來寫延伸模組,或是把 python 内嵌到其他應用服務。使用方式是按照文件規範撰寫 函式如何解析傳入的參數,以及最後如何組成回傳的資料結構。這是在 python2 就有的方案,許多方案都是基於此建構的(EX:pybind11),而有些 python 套件也會自己維護一套生成 python binding 的工具 EX:OpenCV-Python (https://docs.opencv.org/3.4/da/d49/tutorial_py_bindings_basics.html)

優點

  • 原生支援,不需要依賴其他函數庫
  • 可以做更多底層操作

缺點

  • 需管理底層資料的參考管理(reference count),維護成本大
  • 相容性較差,考量到 c api 界面不同 python 版本之後可能會變化

建議使用場景

  • 不推薦直接使用,一來比較難實作,有太多底層 C 細節要關注,二來是如果使用新版 python,c api 界面也會有所變化,相容性要較差

ctypes

ctypes 也是 python 原生支持的方案,可以載入共享函數庫並把 C 的資料結構序列化 python 的物件,達成從 python 呼叫 c function 的功能

優點

  • 原生支援,不需要依賴其他函式庫
  • 被呼叫的函式庫不需要重新編譯

缺點

  • 對 C++ 的支援度不好
  • 存取與變更 C 結構麻煩又容易出錯
  • 無法反方向從 C 程式呼叫 python

建議使用場景

  • 想直接調用編譯好的 c 函式庫(EX:商業函式庫),并且不想再寫一層 wrapper 去包裝,可以使用

cffi

cffi 顧名思義是 C Foreign Function Interface,功能和 ctypes 類似,為 python 提供呼叫 c 函式庫的界面,cffi 有兩種模式 ABI 和 API,ABI 功能和 ctypes 功能相同,直接載入 c library 使用,API 則是需要讀入 c header 檔,使用 c 編譯器產生 python 模組,可以跟其他 python module 一樣的方式做管理,比較容易維護。cffi 也有兩種模式:決定靜態生成 binding 還是執行期動態生成 binding,這幾種模式的組合可以滿足各種情境上的應用

優點

  • 支援 pypy
  • 可以把 c library 包裝成 python module 來維護

缺點

  • 不支援 C++
  • 無法反方向從 C 程式呼叫 python

建議使用場景

  • 需要調用編譯好的 c library,且函式庫是 c 介面

pybind11

在 python c api 外面多包一層 C++ 的 API,可以傳遞 C++ 11 的資料結構和使用 meta-programing 的特性做開發,在編譯期間也會做額外的檢查,讓使用者可以專注在 c++ 的開發,不用直接調用底層 API

優點

  • 支援 C++ 的資料結構、CLASS、多型等特性
  • 輕量型、僅限標頭的程式庫,適合建立現有 C++ 程式碼的 Python 結合

缺點

  • 相對其他方案比較發展比較新(2015),出來時間比較短
  • 需要撰寫 C++ 模板語法

建議使用場景

  • 要串接的專案是以 C++ 爲主,且開發人員習慣以 C++ 作爲開發

Cython

Cython 可以把 python 程式碼編譯成 c 程式碼,能在 CPython 環境執行,並保留原本 python 程式碼的界面,讓其他 python code 可以直接引用。此外,Cython 也有自行定義一套基於 python 的超集語言,可以提供更好的效能。Cython 也有一整套工具鏈把 c/c++ library 包裝成 python module,主要都是 python 語法,對習慣使用 python 的開發者比較容易上手,透過 Cython 對 c/c++ 的良好整合性,加上 binding 也主要是寫 python 語法,讓使用者可以專注在 python 的邏輯開發,遇到效能問題,再把關鍵邏輯用 c/c++ 優化,以此達到增量式優化的開發流程

優點

  • 可以使用類似 python 的語法撰寫 c-extension
  • 高度成熟、 高效能

缺點

  • 要寫 Cython 自己發明的語言,類似 python 的語法
  • 對 C++ 的支援度不如 pybind11

建議使用場景

  • 習慣 python 語法的開發者,可以不斷抽換效能瓶頸,做增量式優化

C/C++ 呼叫 python 函式

一般常見的用法是從 python 呼叫 c/c++ 的 library,既有 python 的靈活性,關鍵地方也可使用 C/C++ 來優化。但另外一個方向也是可行的,也就是把 python 整合到 C/C++ 應用程式,好處是可以使用 python 的靈活性做動態開發,另一方面是當今流行的 AI 資料處理的邏輯大部分是基於 python code,要轉化成 C/C++ 也是需要時間和人力成本,因此在應用中内嵌一個 python 直譯器,讓 C/C++ 可以直接調用 python,加速開發效率。

要在應用程式呼叫 python,就要在應用程式内嵌 python 直譯器,編譯的時候要連接 python 共享函式庫,程式運行的時候會啓動 python 直譯器,這種做法原生 python c api 的官方文章就有教學 [3]。Pybind11 [1] 和 Cython [2] 也有支援和教學如何做到在 C/C++ 調用 python 的程式,可見此種應用場景也是熱門的應用。實務場景會遇到一個問題就是 python code 是明碼,部署在公共平臺上不想讓邏輯直接暴露,會使用上述的 Cython 將 python code 封裝成可執行的 module,做了一種基本的保護,另一方面也些許提升性能。

其他方案 — RPC(gRPC)

在上述的各種方案,要從 python 呼叫 c/c++ library,主要就是要定義介面樣板,最後使用工具產生 wrapper,python 再透過 wrapper 呼叫 library,但實務上,邏輯可能是由公司内兩個不同的組來維護,透過 wrapper 方式會造成開發合作上的困難,如果一方介面改變,另一方就要看懂對方資料結構,重新產生 wrapper,如果沒有良好工具鏈的支持,會導致合作下降。筆者認爲另一個推薦的方式是透過 API 進行合作,定好 API 規格,彼此實作合乎 API 的資料結構,獨立開發,合作起來比較有效率。另一方面,越來越多應用會有抽成獨立服務的需求,不同服務通訊之間會需要 RPC 通訊,例如當今 AI 服務應用都需要載入模型到 GPU 的 RAM 中,如果每次執行都重新載入模型,會影響使用者體驗。應用上大都會把 AI 相關邏輯抽成獨立服務,需要 AI 判讀時再從應用程式透過 RPC/IPC 發送到 AI 服務得到結果後再回傳,類似微服務架構,也是現今流行的方向之一

gRPC 就是 RPC 的一個解決方案,使用方式是寫好 API 的定義檔,即可使用 gRPC 的工具產生用來通訊的 client/server code,不需額外寫傳輸用的 code,還有強型別檢查,降低通信上的 parsing 麻煩事。底層原理是把程式的資料結構編碼成 protobuf 格式利用 HTTP2 通訊協定傳輸到目的地端,之後目的地端再把 protobuf 的檔案格式解碼成程式可讀取的資料結構,以此達成 RPC 的功能

優點

  • gRPC 有支援各種語言,不用額外寫傳輸之間的 code,開發迅速,適合寫不同 code 的人共同合作
  • 可以比較容易處理相同容問題,遇到 client/server 版本有差異的狀況可以更好處理
  • 大家照著通訊定義檔做事,比較沒有歧義,寫文件也比較容易,強型別檢查,不會發生資料轉換出錯的問題
  • 單機版之後可無痛擴展成 server 版

缺點

  • 用於本機端的資料通訊效能不及 share memory 和 pipe 的方式
  • 傳輸影像和影片資料會因爲需要額外的編碼,造成資料和傳輸效率下降

建議使用場景

  • Client 和 server 可以抽成獨立的服務或應用程式
  • 需要跨多種語言合作的專案
  • 專案之後會需要遠端通訊的情況

效能測試

圖1. 測試示意圖

爲了比較使用 binding 和 RPC 效能的差異,筆者做了個簡單實驗,模擬資料從 C++ 傳送到 python 端所需要的時間,測試環境使用 CPU Intel Xeon E3–1268L v5 @2.40GHz,測試的資料會先載入到記憶體,不會從硬碟讀取。測試兩種資料透過兩種不同的通訊方式的耗時(如圖1 所示),第一種資料是未壓縮的影像資料大檔,大小是 14.27 MB,模擬送影像資料給 AI 服務的傳輸時間,另外一種資料是 5 byte 的短字符串資料,模擬傳輸命令所需的傳輸時間。第一種通訊方式採用 C++ 主程式透過 pybind11 送資料到 python code,另一種方式是 C++ 主程式透過 gRPC 把資料送到本地端的 python gRPC server。各測試都跑多次取平均值,結果如下:

Binding vs gRPC test result

測試結果發現 pybind 還是有明顯的損耗效能,尤其是傳送原始影像大檔,會有 11 ms 左右的耗時,是一筆不小的開銷,如果這是關鍵影響的部分,把關鍵程式碼改成直接調用 c/c++,不要走 python 界面,抑或研究 python 的 GIL 問題,可能再傳輸上要做額外設定,繞過 GIL 之類的加速方式。gRPC 效能明顯比 pybind 方案差,一方面是 gRPC 的 protobuf 會封裝資料,而影像資料檔其實不需要封裝,如此會有一半的效能花在影像的資料轉換上,實務上會使用其他傳輸協定傳送影像檔,gRPC 只用來傳送指令和其他 meta 資訊。gRPC 有 RPC 的額外開銷,效能一定不如 pybind 或是其他上述方案,但是耗時仍在可接受的範圍,看實際應用要不要用開發和架構彈性換取效能,如果應用場景不要求極緻效能,可以考慮使用 gRPC 作爲 python 和 C/C++ 的整合方案,例如 Nvidia 的 Triton 計劃就是用 gRPC 作爲 client 和 server 的通訊方式 ( https://github.com/triton-inference-server/server )

總結

筆者比較常用的幾個 python C/C++ 的整合方案:Python/C API、ctypes、cffi、pybind11、Cython,不同方案各有優缺點,除了在一般應用上不建議直接使用 python/c API 之外,依專案開發語言和人力選擇最適合的方案。如果想直接調用編譯好的函式庫,不想再寫一層 wrapper 去包裝函式庫,且函式庫 是 c 介面,可以使用 ctypes 或 cffi。如果要串接的專案是以 C++ 爲主,且開發人員習慣以 C++ 作爲開發,可以考慮使用 pybind11。如果習慣 python 語法的開發者,可以考慮使用 Cython,不斷抽換效能瓶頸,做增量式優化,而 Cython 編譯的特性,可以把 python 程式碼轉成不容易直接讀懂的格式。最後,如果程式可以拆分成 client server 架構,可以使用 gRPC 作爲通訊方式,提升開發和協作效率。

其實還有很多方案沒介紹到例如:SWIG、CPPYY,但篇幅和心力有限,就沒展開探討,可以參考文章 [4],從作者個人感受上來將,覺得 pybind11 應該適合大部分的應用場景,Cython 工具要解決的目標很多,不單純是個 binding 工具,拿來做 binding 有點大才小用,在一些簡單專案中,如果想快速調用一些 C 界面的 library,使用 cffi 和 ctypes 會很趁手。如果不是開發套件給第三方使用,作者還是推薦使用 gRPC 做通訊,畢竟許多 python 應用未來還是有機會變成獨立的服務,考慮到擴展性,不如一開始就使用 RPC 架構

Python c/c++ 整合方案比較表

--

--