2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python 3.15でC拡張のframe pointerを消すと何が起きるか

2
Posted at

Python 3.15でC拡張のframe pointerを消すと何が起きるか

Python 3.15では、対応プラットフォームでCPythonをframe pointer付きでビルドする方針が採用されました。
しかし、途中のC拡張が-fomit-frame-pointerでビルドされていると、システムプロファイラのスタック追跡は途切れる可能性があります。
C拡張の最適化フラグは、実行速度だけでなく観測可能性にも影響します。
同じPythonコードから呼ぶC拡張を2種類ビルドし、perfで違いを確認します。

Pythonから呼ぶC拡張にホットパスを作る

検証環境はLinux x86-64、CPython 3.15、GCC、setuptools、Linux perfを想定します。

確認したいのはPythonの実行速度ではなく、ネイティブコードに入ったときのコールスタック復元です。

次の呼び出し経路を作ります。

Python main()
  -> nativefp.run()
       -> outer_loop()
            -> inner_loop()
                 -> CPU演算

inner_loop()とouter_loop()を別関数にし、インライン化を抑制します。

まずnativefp.cを作成します。

#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <stdint.h>

#if defined(__GNUC__)
#define NOINLINE __attribute__((noinline))
#else
#define NOINLINE
#endif

static volatile uint64_t sink;

static NOINLINE uint64_t inner_loop(uint64_t n) {
    uint64_t x = 1;
    for (uint64_t i = 0; i < n; ++i) {
        x = x * 1664525u + 1013904223u;
    }
    return x;
}

static NOINLINE void outer_loop(uint64_t n) {
    sink = inner_loop(n);
}

static PyObject *run(PyObject *self, PyObject *args) {
    unsigned long long n;
    if (!PyArg_ParseTuple(args, "K", &n))
        return NULL;

    Py_BEGIN_ALLOW_THREADS
    outer_loop((uint64_t)n);
    Py_END_ALLOW_THREADS

    Py_RETURN_NONE;
}

static PyMethodDef methods[] = {
    {"run", run, METH_VARARGS, "Run CPU work."},
    {NULL, NULL, 0, NULL}
};

static struct PyModuleDef module = {
    PyModuleDef_HEAD_INIT, "nativefp", NULL, -1, methods
};

PyMODINIT_FUNC PyInit_nativefp(void) {
    return PyModule_Create(&module);
}

演算結果をvolatile変数に保存し、計算全体が不要として削除されることを防ぎます。

Py_BEGIN_ALLOW_THREADSでGILを解放していますが、Pythonオブジェクトを操作しないCPU演算区間に限定しています。

次にsetup.pyを作成します。

import os
from setuptools import Extension, setup

omit = os.environ.get("OMIT_FP") == "1"

flags = [
    "-O2",
    "-g",
    "-fno-inline",
    "-fno-optimize-sibling-calls",
    "-fomit-frame-pointer" if omit
    else "-fno-omit-frame-pointer",
]

setup(
    name="nativefp-demo",
    version="0.1.0",
    ext_modules=[
        Extension(
            "nativefp",
            sources=["nativefp.c"],
            extra_compile_args=flags,
        )
    ],
)

比較対象は最後のフラグです。

-fno-optimize-sibling-callsは末尾呼び出し最適化によるフレーム消失を避けるために指定します。

実行側のbench.pyは次のとおりです。

import sysconfig
import time
import nativefp

print("CFLAGS:", sysconfig.get_config_var("CFLAGS"), flush=True)

start = time.perf_counter()
nativefp.run(1_000_000_000)
elapsed = time.perf_counter() - start

print(f"elapsed: {elapsed:.3f}s")

sysconfigのCFLAGSはCPython側のビルド設定です。拡張モジュールに最終的に適用されたフラグとは限らないため、ビルドログも確認します。

frame pointerがないとスタックが途切れる理由

Python 3.15でC拡張のframe pointerを消すと何が起きるか

frame pointerは、関数のスタックフレームをたどるための基準です。

x86-64では一般にRBPレジスタが使われます。

frame pointerを保持する関数では、典型的に次の命令が生成されます。

push %rbp
mov  %rsp, %rbp

以前のフレームへの参照を保存し、現在のフレームの基準を設定します。

プロファイラは、この参照をたどって呼び出し元を復元できます。

一方、-fomit-frame-pointerでは、コンパイラがRBPを汎用レジスタとして利用でき、フレームの連鎖が維持されない場合があります。

frame pointerあり

Python
  -> nativefp.run
       -> outer_loop
            -> inner_loop


frame pointerなし

Python
  -> nativefp.run
       -> outer_loop
            -> inner_loop
                 ↑
           追跡が途切れる可能性

実際の関数呼び出しが消えるわけではありません。

実行経路は存在していても、frame pointer方式でスタックを復元するプロファイラから見えなくなることがあります。

ただし、frame pointerがなくてもDWARFのunwind情報などから復元できる場合があります。

今回の比較は、perfのframe-pointer方式に限定します。

2種類のwheelを作ってperfで比較する

同じソースコードから、frame pointerを保持するwheelと省略するwheelを作ります。

Python 3.15環境を用意します。

python3.15 -m venv .venv
source .venv/bin/activate

python -m pip install --upgrade pip
python -m pip install setuptools wheel build

GCCとPython開発用ヘッダーも必要です。

まず保持するwheelをビルドします。

rm -rf build dist
OMIT_FP=0 python -m build --wheel --no-isolation
mkdir -p wheels/keep
cp dist/*.whl wheels/keep/

次に省略するwheelです。

rm -rf build dist
OMIT_FP=1 python -m build --wheel --no-isolation
mkdir -p wheels/omit
cp dist/*.whl wheels/omit/

パッケージ名とバージョンが同じなので、インストール先を分離します。

mkdir -p installs/keep installs/omit

python -m pip install --no-deps --target installs/keep wheels/keep/*.whl
python -m pip install --no-deps --target installs/omit wheels/omit/*.whl

perfでスタックを取得します。

PYTHONPATH=installs/keep \
perf record -g --call-graph fp -o keep.data \
python bench.py

PYTHONPATH=installs/omit \
perf record -g --call-graph fp -o omit.data \
python bench.py

結果を表示します。

perf report --stdio -i keep.data
perf report --stdio -i omit.data

確認するのはinner_loopのCPU使用率だけではありません。

inner_loopからouter_loop、さらにrunまで呼び出し元を復元できるかを比較します。

frame pointerを保持したビルドでは、関数の親子関係を追跡しやすくなることが期待できます。

省略したビルドでは、サンプルが取得できても呼び出し元が欠落する可能性があります。

ただし、表示結果はコンパイラやカーネル、perfのバージョンによって変わります。未計測の結果を固定値として示すことはできません。

フラグが反映されたかは逆アセンブルでも確認します。

objdump -d installs/keep/nativefp*.so > keep.asm
objdump -d installs/omit/nativefp*.so > omit.asm

outer_loopとinner_loopのスタック操作を比較してください。

なお、perfには実行権限やkernel.perf_event_paranoidの設定が関係します。コンテナ環境では計測できない場合もあります。

Python 3.15の変更を拡張側にも反映する

2026年4月30日に採択されたPEP 831は、対応プラットフォームでCPythonをframe pointer付きでビルドする方針を定めています。

Python 3.15.0rc3は2026年10月2日に公開され、正式版は10月9日が予定日として案内されていました。

ここで注意したいのは、CPython本体のビルド方針が変わっても、C拡張のコンパイル条件まで自動的に統一されるわけではないことです。

たとえば、拡張側に次の指定が残っている場合です。

Extension(
    "nativefp",
    sources=["nativefp.c"],
    extra_compile_args=[
        "-O2",
        "-fomit-frame-pointer",
    ],
)

CPython側の設定だけを見ても、この明示的なフラグを見落とす可能性があります。

frame pointerを保持するなら次のように変更します。

Extension(
    "nativefp",
    sources=["nativefp.c"],
    extra_compile_args=[
        "-O2",
        "-fno-omit-frame-pointer",
    ],
)

これはGCCやClang向けの指定です。MSVCなどでは別の対応が必要です。

また、sysconfigから取得できる設定と、実際のコンパイルコマンドは区別します。

import sysconfig

for key in ("CC", "CFLAGS", "PY_CFLAGS", "LDSHARED"):
    print(key, sysconfig.get_config_var(key))

この情報だけで拡張のframe pointer保持を保証することはできません。

Cythonで生成したCコードや外部ライブラリを利用する場合も同様です。

拡張本体だけを修正しても、その先の共有ライブラリがframe pointerを省略していれば、追跡が途切れる可能性があります。

速度と観測可能性を同じ条件で測る

frame pointerを保持すると、スタック管理の命令が増えたり、利用できる汎用レジスタが制限されたりする場合があります。

性能上のコストがゼロとは限りません。

ただし、-fomit-frame-pointerによって必ず速度が向上するわけでもありません。

今回のCPU演算ループでは、関数呼び出しのコストよりもループ内部の演算が支配的になる可能性があります。

比較するときは、次の条件をそろえます。

  1. Python、コンパイラ、CPU、最適化レベルを固定する
  2. frame pointer関連以外のフラグを統一する
  3. perfなしの実行時間も複数回測る
  4. CPU負荷や周波数変動を考慮する
  5. 実行時間とスタック復元の結果を別々に評価する

perfのサンプリングにはオーバーヘッドがあります。プロファイル中の実行時間だけで最適化効果を判断しない方がよいです。

また、今回のコードはPythonからC拡張へ入る経路を単純化したものです。Pythonフレームの詳細な復元まで検証するものではありません。

Pythonとネイティブコードをまたぐ完全なスタックを調べるには、プロファイラのPython対応やunwinderの方式も確認する必要があります。

perfにはDWARF方式など別のスタック取得方法もあります。frame pointerを保持できない依存ライブラリがある場合は、別方式で復元できるかを調べる選択肢があります。

本番でCPU使用率が急増したとき、どの処理からネイティブコードへ入ったのかを追えるかどうかは、障害調査に影響します。

frame pointerの省略は、単純な速度最適化ではなく、観測可能性を含めたビルド条件として判断したいところです。

今回の再現はLinux x86-64とGCCが前提です。macOS、Windows、異なるCPUアーキテクチャでは同じ結果になるとは限りません。実際に配布するwheelと依存ライブラリを対象に、実行時間とスタック復元の両方を確かめる必要があります。

参考

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?