ctypesとcffiを使ってPythonからCライブラリを呼び出す:Cythonなしで重いCPU処理を高速化する

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

深夜2時、Pythonスクリプトがボトルネックに

02:17、アラートが鳴り響く。1バッチあたり8万件のレコードを処理するデータパイプラインジョブが、1回の実行に94秒かかっている。SLAは20秒だ。Datadogのグラフを見つめると、原因は明らかだった——バイナリバッファに対してカスタムのビット演算エンコードを行う1つのPython関数。タイトなループの中で8万回呼び出されている。

あらゆる手を尽くしてきた——適用できる箇所にはnumpyのベクトル化、functools.lru_cache、さらにはmultiprocessingまで。それでも30秒を切れない。ボトルネックはPythonインタープリターのループ内で行われる生のCPU演算であり、GILに縛られたバイトが1つずつ処理されているからだ。

その夜に実際に効いた修正——そしてそれ以来、本番環境で継続的に使い続けている手法——は、ctypesを使ってPythonから小さなC関数を呼び出すことだった。新しいビルドシステムも、Cythonのセットアップも、C拡張モジュールの定型コードも不要。共有ライブラリとわずか数行のPythonだけでいい。

根本原因:純粋なCPU処理でPythonが遅くなる理由

Pythonが遅いのは出来が悪いからではない——CPUバウンドなループで遅いのは、すべての処理がインタープリターのevalループを経由するからだ。バイトコード命令ごとにオーバーヘッドが発生する:型ディスパッチ、参照カウント、GIL取得。I/Oバウンドな処理やオーケストレーションのロジックなら、そのオーバーヘッドは目立たない。しかし、数百万回繰り返される密な算術ループでは、それが積み重なって大きな差になる。

GIL(グローバルインタープリターロック)のせいで、CPU処理においてスレッドは役に立たない。multiprocessingは助けになるが、プロセス間のシリアライズコストとメモリの重複が生じる。本当に必要なのは、そのホットパスに限ってPythonから完全に抜け出すこと——コンパイル済みのマシンコードを実行して、結果を持ち帰ることだ。

ctypescffiはどちらもそれを可能にする。ユーザー側でのコンパイルステップなしに、実行時にPythonからコンパイル済みCコードを呼び出せるのだ。

選択肢の概要

ctypes — Python標準搭載、依存関係ゼロ

ctypesはPython 2.5から標準ライブラリに含まれている。共有ライブラリ(Linuxの.so、Windowsの.dll、macOSの.dylib)を読み込み、エクスポートされた関数を直接呼び出せる。Python拡張モジュールのコンパイルは不要だ。必要なのはCコードを共有ライブラリにコンパイルするだけ——gccclangが入っているシステムなら、コマンド1つで完了する。

既存のシステムライブラリ(libclibmlibsslなど)や、自分でコンパイルした小さなカスタムC関数を呼び出すときはctypesを選ぼう。

cffi — よりPythonicで、複雑な構造体を扱いやすい

cffi(C Foreign Function Interface)はサードパーティライブラリで、異なるアプローチをとる。実際のC宣言をそのままPythonに貼り付けると、cffiがバインディングを処理してくれる。構造体、コールバック、ポインター演算を含む複雑なAPIがこの方式で格段に読みやすくなる。バインディングを一度コンパイルしてキャッシュする「アウトオブライン」モードもサポートしており、本番環境でのインポートを高速化できる。

pip install cffiでインストールできる。パッケージはよくメンテナンスされており、主要プラットフォームすべてで動作し、特殊な依存関係もない——どんなrequirements.txtにも素直に追加できる。

なぜCythonではないのか?

Cythonは優れたツールだが、パッケージングパイプラインに影響するビルドステップが必要になる。.pyxファイルを書き、cythonでCコードを生成し、CPythonのABIをターゲットにCコンパイラでコンパイルし、できた.soを配布する。自分で管理する制御されたサーバーなら問題ない。しかし、バラバラな構成のコンテナ群や、ビルドツールチェーンが全員に揃っていないチームでは摩擦の種になる。ctypescffiはそのすべてをスキップする——コンパイル済みのバイナリを呼び出すだけだ。

ctypesの実践:まずシステムライブラリを呼び出す

Cを書く前に、すべてのLinuxシステムに入っているC数学ライブラリlibmでctypesをテストしてみよう:

import ctypes
import math

# 共有ライブラリを読み込む
libm = ctypes.CDLL("libm.so.6")

# 戻り値の型を指定する(デフォルトはc_int)
libm.sqrt.restype = ctypes.c_double
libm.sqrt.argtypes = [ctypes.c_double]

result = libm.sqrt(ctypes.c_double(144.0))
print(result)  # 12.0

読み込みに3行、型宣言に2行、呼び出しに1行。同じパターンをカスタムC関数にも適用してみよう。最小限のCファイルを書く:

// encode.c
#include <stdint.h>

uint32_t encode_buffer(const uint8_t *buf, int len) {
    uint32_t checksum = 0;
    for (int i = 0; i < len; i++) {
        checksum ^= (buf[i] << (i % 8));
        checksum = (checksum << 1) | (checksum >> 31);
    }
    return checksum;
}

コマンド1つで共有ライブラリにコンパイルする:

gcc -O2 -shared -fPIC -o encode.so encode.c

Pythonから読み込んで呼び出す:

import ctypes

lib = ctypes.CDLL("./encode.so")
lib.encode_buffer.restype = ctypes.c_uint32
lib.encode_buffer.argtypes = [
    ctypes.POINTER(ctypes.c_uint8),
    ctypes.c_int
]

data = bytes(range(256)) * 100  # 25,600バイト
buf = (ctypes.c_uint8 * len(data)).from_buffer_copy(data)

result = lib.encode_buffer(buf, len(data))
print(f"チェックサム: {result:#010x}")

あのインシデントの夜、Pure Python実装ではフルバッチで94秒かかっていた。ホット関数を15行のCに落とし込み、ctypes経由で呼び出すようにしたところ、バッチは6.8秒で完了した。同じロジック、同じ出力、同じテストスイートがパス。

cffiの実践:複雑なバインディングをすっきり記述する

C APIが構造体を使う場合や、より自然に読めるバインディングが欲しい場合は、cffiの方が適している。同じencode関数をcffiのインラインモードでラップしてみよう:

import cffi

ffi = cffi.FFI()

# C宣言をそのまま貼り付ける — cffiが解析する
ffi.cdef("""
    uint32_t encode_buffer(const uint8_t *buf, int len);
""")

lib = ffi.dlopen("./encode.so")

data = bytes(range(256)) * 100
buf = ffi.new("uint8_t[]", data)

result = lib.encode_buffer(buf, len(data))
print(f"チェックサム: {result:#010x}")

ffi.new()の呼び出しはCが管理するバッファを確保する——cffiがメモリレイアウトを処理してくれる。ネストした構造体を持つAPIでは、このアプローチはctypesの手動フィールド宣言よりもはるかにスケールしやすい。

本番環境での使用には、cffiのアウトオブラインABIモードを推奨する。バインディングを別のビルドスクリプトで定義し、一度だけ実行してコンパイル済みバインディングを.soとしてキャッシュしておこう:

# build_bindings.py(一度だけ実行)
import cffi
ffi = cffi.FFI()
ffi.cdef("uint32_t encode_buffer(const uint8_t *buf, int len);")
ffi.set_source("_encode_binding", '#include "encode.h"',
               sources=["encode.c"],
               extra_compile_args=["-O2"])

if __name__ == "__main__":
    ffi.compile(verbose=True)
python build_bindings.py

これ以降、インポートは高速になり、実行時のコンパイルオーバーヘッドもなくなる:

# main.py
from _encode_binding import ffi, lib

buf = ffi.new("uint8_t[]", data)
result = lib.encode_buffer(buf, len(data))

注意すべき落とし穴

型の不一致は致命的なクラッシュを引き起こす

ctypesとcffiは、C関数に誤った型を渡すことから守ってくれない。ポインターが不一致だとインタープリターがセグフォルトでクラッシュする——Pythonのトレースバックはなく、プロセスが死ぬだけだ。ctypesでは必ずargtypesrestypeを明示的に宣言しよう。cffiのcdef()は実際のC宣言を解析するため、これを自動的に処理してくれる。

メモリの所有権はあなたの責任

C関数がヒープに確保されたメモリへのポインターを返す場合、そのメモリはあなたが所有する。Pythonのガベージコレクターはそのポインターの存在を知らない。対応する解放関数を呼び出さなければ、本番環境でメモリリークが発生する——次の深夜2時を楽しく過ごす羽目になるかもしれない。

# Cがメモリを所有する場合はallocとfreeを必ずペアで使う
ptr = lib.create_buffer(1024)
try:
    # ... ptr を使用 ...
    pass
finally:
    lib.free_buffer(ptr)

並列処理のためのGIL解放

ctypesはC関数呼び出し中にGILを自動的に解放する。つまり、Pythonスレッドを使ってCPUバウンドなCコードを並列実行できる——純粋なPythonのCPU処理では不可能なことだ。ワークロードが並列化しやすい場合は、ctypesとconcurrent.futures.ThreadPoolExecutorを組み合わせることで、複数コアでほぼ線形なスケーリングが得られる。

ctypes vs cffi:使い分けの基準

  • ctypesを使うべき場合:既存のシステムライブラリを呼び出すとき、手早く一時的なバインディングを書くとき、または追加の依存関係をゼロにしたいとき。
  • cffiを使うべき場合:C APIが構造体、コールバック、複雑なポインター型を含むとき、バインディングをCの宣言として読めるようにしたいとき、または本番性能のためのアウトオブラインコンパイルモードが必要なとき。
  • どちらも避けるべき場合:クラスやテンプレートを含む大規模なC++ APIをラップするとき——その場合はpybind11を使おう。

ベンチマークの実測値

本番インシデントのエンコードループの結果:

  • 8万件のレコードをPure Pythonでループ:94秒
  • NumPyでベクトル化した近似実装:31秒(アルゴリズムが異なるため完全一致ではない)
  • ctypesで-O2コンパイルしたCを呼び出し:6.8秒
  • cffiアウトオブライン、-O35.9秒

それ以来、このアプローチは3つの別々のサービスで本番運用されている。クラッシュなし。コードレビュー中に2件のメモリリークを見つけて修正した後は、リークなし。Cコード自体がシンプルなため、置き換えたPythonよりも欠陥発生率が低い——実際に計測してみると、誰もが納得する結果だった。

ソリューション全体——Cファイル、gccコマンド、Pythonバインディング——が単一のgitコミットに収まる。同僚が読め、CIがビルドでき、本番コンテナが実行時に.soを読み込む。ビルドシステムの変更は一切不要だ。

Share: