深夜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から完全に抜け出すこと——コンパイル済みのマシンコードを実行して、結果を持ち帰ることだ。
ctypesとcffiはどちらもそれを可能にする。ユーザー側でのコンパイルステップなしに、実行時にPythonからコンパイル済みCコードを呼び出せるのだ。
選択肢の概要
ctypes — Python標準搭載、依存関係ゼロ
ctypesはPython 2.5から標準ライブラリに含まれている。共有ライブラリ(Linuxの.so、Windowsの.dll、macOSの.dylib)を読み込み、エクスポートされた関数を直接呼び出せる。Python拡張モジュールのコンパイルは不要だ。必要なのはCコードを共有ライブラリにコンパイルするだけ——gccかclangが入っているシステムなら、コマンド1つで完了する。
既存のシステムライブラリ(libc、libm、libsslなど)や、自分でコンパイルした小さなカスタム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を配布する。自分で管理する制御されたサーバーなら問題ない。しかし、バラバラな構成のコンテナ群や、ビルドツールチェーンが全員に揃っていないチームでは摩擦の種になる。ctypesとcffiはそのすべてをスキップする——コンパイル済みのバイナリを呼び出すだけだ。
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では必ずargtypesとrestypeを明示的に宣言しよう。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アウトオブライン、
-O3:5.9秒
それ以来、このアプローチは3つの別々のサービスで本番運用されている。クラッシュなし。コードレビュー中に2件のメモリリークを見つけて修正した後は、リークなし。Cコード自体がシンプルなため、置き換えたPythonよりも欠陥発生率が低い——実際に計測してみると、誰もが納得する結果だった。
ソリューション全体——Cファイル、gccコマンド、Pythonバインディング——が単一のgitコミットに収まる。同僚が読め、CIがビルドでき、本番コンテナが実行時に.soを読み込む。ビルドシステムの変更は一切不要だ。

