PEP 831 – 어디서나 프레임 포인터 사용: Python의 시스템 수준 관측 가능성 활성화
- Author:
- Pablo Galindo Salgado <pablogsal at python.org>, Ken Jin <kenjin at python.org>, Savannah Ostrowski <savannah at python.org>, Diego Russo <diego.russo at arm.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Created:
- 14-Mar-2026
- Python-Version:
- 3.15
- Post-History:
- 13-Apr-2026
- Resolution:
- 30-Apr-2026
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 두 가지를 제안합니다:
- 프레임 포인터를 지원하는 플랫폼에서 기본적으로 프레임 포인터를 사용하여 CPython을 빌드하십시오. 기본 빌드 구성이 변경되어 인터프리터를
-fno-omit-frame-pointer및-mno-omit-leaf-frame-pointer와 함께 컴파일합니다. 이 플래그는CFLAGS에 추가되므로 인터프리터 자체에 적용되며,sysconfig를 통해 이 Python에 맞춰 빌드되는 C 확장 모듈에도 전달됩니다. 최대 원시 처리량이 필요한 배포를 위해 옵트아웃configure플래그(--without-frame-pointers)를 제공합니다. - Python 생태계의 모든 빌드 시스템이 기본적으로 프레임 포인터를 사용하여 빌드하도록 강력히 권장하십시오. 이 PEP는 Python 호출 스택에 참여하는 모든 컴파일된 구성 요소(C 확장, Rust 확장, 임베딩 애플리케이션 및 네이티브 라이브러리)가 프레임 포인터를 활성화해야 한다고 권장합니다. 프레임 포인터 체인의 강도는 가장 약한 연결 고리만큼 강합니다. 프레임 포인터가 없는 단일 라이브러리만으로도 전체 프로세스의 프로파일링, 디버깅 및 추적이 중단됩니다.
프레임 포인터는 프로파일러, 디버거 및 시스템 추적 도구가 실행 중인 프로세스의 호출 스택을 빠르고 안정적으로 재구성할 수 있게 하는 CPU 레지스터 규약입니다. 프레임 포인터를 생략하면(컴파일러가 -O1 이상에서 기본적으로 수행하는 동작입니다) 이러한 도구가 Python 프로세스에 유용한 호출 스택을 생성하지 못하며, CPython이 3.12에서 제공한 perf 트램펄린 지원이 약화됩니다.
일반적인 워크로드에서 측정된 오버헤드는 기하 평균 기준 2% 미만입니다(Backwards Compatibility에서 플랫폼별 수치를 참조하십시오). 여러 주요 Linux 배포판, 언어 런타임 및 Python 생태계 도구가 이미 이 변경 사항을 채택했습니다. 이 주제를 다루는 기존 PEP는 없으며, CPython 이슈 #96174는 2022년 8월부터 해결되지 않은 채 열려 있습니다.
동기
Python의 관측 가능성(프로파일링, 디버깅 및 시스템 수준 추적)은 프레임 포인터의 부재로 인해 근본적으로 제한됩니다. 이 PEP의 핵심 동기는 Python을 기본적으로 관측 가능하게 만드는 것입니다. 이를 통해 프로파일러는 더 빠르고 정확해지고, 디버거는 더 안정적으로 작동하며, eBPF 기반 도구는 우회 방법 없이 기능할 수 있습니다.
오늘날 시스템 도구로 CPython을 프로파일링하려는 사용자는 특수 컴파일러 플래그를 사용하여 인터프리터를 다시 빌드해야 하지만, 대부분의 사용자는 이 작업을 수행할 수 없거나 수행하려 하지 않습니다. Fedora 38 프레임 포인터 제안 [2]은 이를 핵심 문제로 강조합니다. 기본 빌드에 프레임 포인터가 없으면 개발자는 “충분한 디버깅 정보와 함께 프로그램을 다시 컴파일하고” “소프트웨어 성능이 저하된 상황을 재현해야” 하는데, 이는 프로덕션 문제에서는 흔히 불가능합니다. Ubuntu 24.04의 분석 [3]도 같은 주장을 합니다. 프레임 포인터를 사용하면 “bcc-tools, bpftrace, perf 및 이와 같은 기타 도구가 별도의 설정 없이 작동할 수 있습니다.” 이 PEP의 목표는 이를 Python의 기본 경험으로 만드는 것입니다.
프로파일링이 가능하게 하는 성능 개선은 프레임 포인터의 적당한 오버헤드를 훨씬 능가합니다. Brendan Gregg가 지적하듯이, “프레임 포인터가 5%에서 500%에 이르는 성능 개선을 찾는 데 도움을 주는 경우를 보았습니다” [1]. 이러한 개선은 프로덕션 시스템에서 핫 패스를 식별함으로써 얻어집니다. CPython 자체의 오버헤드에 관한 것이 아니라, 전체 스택에서 프로파일링이 가능하게 하는 작업에 관한 것입니다. 이러한 통찰을 가능하게 하는 0.5~2%의 오버헤드는 유리한 절충안입니다.
프레임 포인터란 무엇입니까?
프로그램이 실행되면 각 함수 호출은 스택 프레임을 생성합니다. 이는 호출 스택에 있는 메모리 블록으로, 함수의 지역 변수와 인자, 그리고 함수가 종료될 때 돌아갈 주소를 저장합니다. 호출 스택은 모든 활성 스택 프레임의 연결입니다. main()부터 현재 실행 중인 함수까지 어떤 함수가 어떤 함수를 호출했는지를 기록합니다.
프레임 포인터는 각 함수가 자신의 스택 프레임의 기반을 가리키도록 설정하는 CPU 레지스터입니다(예를 들어 x86-64에서는 %rbp, AArch64에서는 x29입니다). 각 프레임은 이전 프레임 포인터도 저장하여 전체 호출 스택을 관통하는 연결 리스트를 생성합니다:
┌──────────────────┐
│ main() │ ◄─── frame pointer chain
│ saved %rbp ─────┼──► (bottom of stack)
├──────────────────┤
│ PyRun_String() │
│ saved %rbp ─────┼──► main's frame
├──────────────────┤
│ _PyEval_Eval…() │
│ saved %rbp ─────┼──► PyRun_String's frame
├──────────────────┤
│ call_function() │ ◄─── current %rbp
│ saved %rbp ─────┼──► _PyEval_Eval's frame
└──────────────────┘
스택 언와인딩은 이 체인을 따라가 호출 스택을 재구성하는 과정입니다. 프로파일러는 프로그램이 어디에서 시간을 소비하는지 알아내기 위해 이를 수행하고, 디버거는 백트레이스를 표시하기 위해 수행하며, 크래시 처리기는 유용한 오류 보고서를 생성하기 위해 수행합니다. 프레임 포인터가 있으면 언와인딩은 단순히 포인터를 따라가는 작업입니다. %rbp를 읽고, 연결을 따라가고, 이를 반복합니다. 외부 데이터가 필요하지 않습니다.
최적화 수준 -O1 이상에서 GCC와 Clang은 기본적으로 프레임 포인터를 생략합니다 [14]. 이를 통해 %rbp 레지스터를 일반 용도로 사용할 수 있게 되어, 최적화기가 사용할 수 있는 레지스터가 하나 더 생깁니다. x86-64에서는 16개 레지스터 중 하나가 늘어나는 것으로, 약 7%의 증가입니다. 성능 향상은 작지만(일반적으로 몇 퍼센트) 32비트 x86에서 이 규약이 정립되었을 당시에는 6개 레지스터 중 하나가 늘어나는 것(약 20%)이었으므로 이를 감수할 만한 가치가 있다고 여겼습니다. 플랫폼과 작업 부하별 전체 분석은 Detailed Performance Analysis of CPython with Frame Pointers을 참조하십시오.
프레임 포인터가 없으면 연결 리스트가 존재하지 않습니다. 호출 스택을 순회해야 하는 도구는 대신 DWARF 디버그 정보(각 함수가 스택 프레임을 배치한 방식을 나타내는 복잡한 가변 길이 인코딩)를 파싱하거나, Windows에서는 .pdata / .xdata 언와인드 메타데이터를 파싱해야 합니다. 이는 더 느리고 취약하며, 일부 컨텍스트(예: Linux 커널 내부)에서는 불가능합니다. 최악의 경우 도구는 손상되었거나 불완전한 결과를 단순히 생성합니다.
구체적인 예를 살펴보겠습니다. 일반적으로 프레임 포인터가 없는 Python 프로세스의 perf 프로파일은 다음과 같이 표시됩니다.:
100.00% python libpython3.14.so _PyEval_EvalFrameDefault
|
---_PyEval_EvalFrameDefault
(truncated, no further frames available)
프레임 포인터가 있는 동일한 프로파일은 전체 체인을 표시합니다.:
100.00% python libpython3.14.so _PyEval_EvalFrameDefault
|
---_PyEval_EvalFrameDefault
|
+--PyObject_Call
| PyRun_StringFlags
| PyRun_SimpleStringFlags
| Py_RunMain
| main
|
+--call_function
fast_function
...
첫 번째 트레이스는 성능 문제를 진단하는 데 쓸모가 없지만, 두 번째 트레이스는 정확히 어떤 코드 경로가 집중적으로 실행되는지 개발자에게 알려 줍니다.
프레임 포인터가 없으면 프로파일러는 더 느리고 정확도도 떨어집니다.
통계적 프로파일러(perf, py-spy, Austin, Pyroscope, Parca 등)는 실행 중인 프로세스의 호출 스택을 주기적으로 샘플링하는 방식으로 작동합니다. 프레임 포인터가 있으면 이 샘플링은 단순한 포인터 추적입니다. 프로파일러가 %rbp를 읽고 체인을 따라가며 몇 마이크로초 안에 전체 호출 스택을 재구성합니다. 이는 운영 환경에서 오버헤드를 거의 발생시키지 않고 10,000Hz로 샘플링할 수 있을 만큼 빠릅니다.
프레임 포인터가 없으면 프로파일러는 DWARF 언와인딩으로 대체해야 합니다. 이는 컴파일러가 생성한 디버그 메타데이터를 파싱하여 호출 체인을 재구성하는 방법입니다. perf --call-graph dwarf는 샘플마다 원시 스택 8KB를 사용자 공간으로 복사한 다음, 오프라인에서 .eh_frame 디버그 섹션을 파싱하여 각 프레임을 재구성합니다. CPython 3.15-dev에서 직접 측정한 결과, 동일한 작업 부하를 동일한 샘플링 속도로 프로파일링했을 때 프레임 포인터 언와인딩에서는 5.6MB의 perf.data 파일이 생성된 반면, DWARF 언와인딩에서는 동일한 약 38,000개 샘플에 대해 306.5MB 파일이 생성되었습니다(55배 더 큽니다).
DWARF 모드에서는 perf report를 사용한 오프라인 후처리도 필요하고(이 작업 자체가 CPU의 최대 17%를 사용할 수 있습니다 [11]), 복사 창보다 깊은 스택을 조용히 잘라 내며, 운영 환경에서 높은 샘플링 속도로 사용할 수 없습니다. 결과적으로 운영 환경에서 Python 서비스를 프로파일링하려면 손상된 스택을 받아들이거나, 몇 자릿수 더 큰 오버헤드와 저장 공간을 받아들여야 합니다.
차이를 정량화하기 위해 x86-64 Linux에서 프레임 포인터 순회(%rbp 체인 추적, 커널의 perf_events 및 bpf_get_stackid()에서 사용하는 방법)와 널리 사용되는 세 가지 DWARF 기반 언와인더인 libunwind [26], glibc의 backtrace(), 그리고 samply [28]에서 사용하는 Rust 언와인더인 framehop [27]을 사용하여 64프레임 호출 스택을 언와인드하는 시간을 벤치마크했습니다. 각 언와인더는 -fno-omit-frame-pointer 옵션을 사용한 경우와 사용하지 않은 경우로 컴파일한 동일한 프로그램을 대상으로 테스트했습니다.
프레임 포인터 순회는 64프레임 언와인드를 116ns에 완료했으며, 테스트한 DWARF 대안보다 평균 210배 빠릅니다. 프레임 포인터가 없으면 프레임 포인터 순회는 사용 가능한 프레임을 0개 복구하지만(%rbp 체인이 존재하지 않음), DWARF 언와인더는 본질적으로 동일한 비용으로 계속 작동합니다.
일반적인 운영 환경 샘플링 속도인 10,000Hz에서 이 깊이의 프레임 포인터 언와인딩은 초당 약 1.2ms의 CPU 시간(0.12%)을 사용하는 반면, 테스트한 가장 느린 DWARF 언와인더는 초당 240ms 이상(24%)을 사용합니다. DWARF 기반 프로파일링은 작동하며 오늘날 대부분의 프로파일러가 사용하는 방식이지만, 프레임 포인터 언와인딩보다 오버헤드가 상당히 큽니다.
BPF 기반 프로파일러(bpftrace, bcc의 profile.py, Pyroscope, Parca, Elastic Universal Profiling)에서는 상황이 더 나쁩니다. 오늘날 운영 환경의 모든 eBPF 프로파일러를 뒷받침하는 기반인 BPF 헬퍼 bpf_get_stackid()는 프레임 포인터 체인을 순회하며 DWARF로의 대체 경로가 없습니다. 프레임 포인터가 없으면 이러한 도구는 Python 프로세스에 대해 잘리거나 비어 있는 스택을 단순히 생성합니다. Linux 커널에는 DWARF 언와인더가 없으며 Linus Torvalds에 따르면 앞으로도 추가되지 않을 것입니다 [15]. 대신 커널은 내부 사용을 위해 자체 ORC 형식을 개발했습니다.
그 영향은 CPU 프로파일링을 넘어섭니다. 오프-CPU 플레임 그래프(I/O 대기, 잠금 경합 및 스케줄링 지연으로 인한 지연 시간을 진단하는 데 사용됨)는 스레드가 블록되는 지점에서 스택을 캡처하기 위해 동일한 bpf_get_stackid() 헬퍼에 의존합니다. Brendan Gregg가 지적했듯이, 오프-CPU 플레임 그래프는 “libc read/write 및 mutex 함수가 대부분을 차지할 수 있으므로, 프레임 포인터가 없으면 대부분 손상됩니다” [1]. 원시 CPU 처리량보다 지연 시간이 중요한 Python 서비스에서는 오프-CPU 프로파일링이 가장 가치 있는 진단 도구인 경우가 많으며, 프레임 포인터가 없으면 완전히 작동하지 않습니다.
프레임 포인터는 디버거에 이점을 제공합니다
GDB 및 LLDB와 같은 디버거는 프레임 포인터 없이도 스택을 언와인드할 수 있습니다. 이러한 디버거는 여러 전략을 사용합니다. DWARF CFI 메타데이터(.debug_frame 및 .eh_frame 섹션), 어셈블리 프롤로그 분석, 컴팩트 언와인드 정보(macOS에서 사용), 그리고 다양한 플랫폼별 휴리스틱이 이에 해당합니다. 완전한 디버그 정보를 사용할 수 있는 일반적인 대화형 디버깅 세션에서는 이러한 메커니즘이 잘 작동합니다.
그럼에도 프레임 포인터는 몇 가지 중요한 시나리오에서 디버깅을 더 빠르고 견고하게 만듭니다.
프로덕션 배포에서는 일반적으로 디버그 심볼을 제거하거나 일치하는 debuginfo 패키지 없이 제공됩니다. DWARF 메타데이터를 사용할 수 없으면 디버거는 해당 공백을 넘어 스택을 언와인드할 수 없습니다. 프레임 포인터는 바이너리 스트리핑 후에도 유지되고 사이드 채널 데이터가 필요하지 않으므로, DWARF 기반 언와인딩으로는 불가능한 경우에도 백트레이스를 성공시킬 수 있습니다. 이는 코어 덤프 분석에서 가장 중요합니다. 프로덕션 프로세스의 충돌을 분석할 때 디버거가 스택을 재구성할 기회는 한 번뿐이며, 일부 공유 객체에서 디버그 패키지가 일치하지 않거나 없으면 DWARF 언와인딩은 첫 번째 공백에서 중단되는 반면 프레임 포인터를 사용하면 디버거가 그 공백을 통과해 계속 진행할 수 있습니다.
CPython의 JIT 스텐실과 perf 트램펄린 스텁에는 DWARF 메타데이터가 없습니다. 디버거가 이러한 프레임을 통과해 언와인드할 수 있는 유일한 방법은 프레임 포인터입니다.
elfutils(libdw)를 사용해 코어 파일과 원격 프로세스를 분석하는 pystack과 같은 도구는 추가 메타데이터 없이 프레임 포인터를 따라갈 수 있지만, 프레임 포인터가 없으면 프로세스의 모든 공유 객체에 대한 디버그 심볼을 필요로 하며, 이는 프로덕션 컨테이너에서는 좀처럼 충족되지 않는 조건입니다.
프레임 포인터 언와인딩은 또한 상당히 더 빠릅니다. 위의 벤치마크에서 보인 것처럼 프레임 포인터 워크는 64개 프레임의 언와인딩을 116ns에 완료하며, 이는 DWARF 대안보다 약 210배 빠릅니다. 반복적으로 언와인드하는 디버거 작업(예: 적중할 때마다 평가되는 조건부 중단점)에서는 이러한 차이가 중요합니다.
커널의 스택 언와인더는 프레임 포인터만 사용합니다
Linux 커널은 사용자 공간 호출 스택을 캡처하기 위한 두 가지 내장 메커니즘을 제공합니다. perf_events 서브시스템과 eBPF 헬퍼 함수가 이에 해당합니다. 둘 다 동일한 커널 측 프레임 포인터 언와인더를 사용하며 DWARF로의 폴백도 전혀 없습니다.
perf_events는 perf record의 기반이 되는 커널 서브시스템입니다. perf가 --call-graph fp(프레임 포인터)로 구성되면 커널은 샘플을 캡처한 인터럽트 핸들러에서 사용자 공간 프레임 포인터 체인을 직접 따라갑니다. 이는 사용자 공간의 협력 없이 커널 컨텍스트에서 인터럽트 시간에 발생합니다. 언와인더는 %rbp 체인을 따라가며 대상 프로세스의 스택에서 저장된 각 프레임 포인터를 읽고, 스택의 최하단이나 구성 가능한 깊이 제한에 도달할 때까지 계속합니다. 그 결과는 반환 주소의 압축 배열이며, perf는 이를 오프라인에서 심볼로 해석합니다. 이는 오버헤드가 가장 낮은 경로입니다. 주소 배열 자체를 제외하면 사용자 공간으로 복사되는 데이터가 없으며, 커널은 마이크로초 단위로 워크를 수행합니다.
프레임 포인터가 없으면 perf_events는 커널 내에서 스택을 전혀 언와인드할 수 없습니다. 유일한 대안은 --call-graph dwarf이지만, 이는 실제로 커널 내에서 언와인드하지 않습니다. 대신 샘플마다 최대 8KB의 원시 스택 메모리를 perf.data 링 버퍼로 복사하고, 언와인딩은 사용자 공간에서 perf report에 의해 오프라인으로 수행됩니다. 이는 커널 측 언와인딩이 아니라, 대량 메모리 복사 후 오프라인에서 DWARF를 해석하는 과정입니다.
eBPF는 작은 프로그램을 커널 내부에서 안전하게 실행할 수 있도록 하는 Linux 커널 기술로, 낮은 오버헤드의 시스템 모니터링, 프로파일링 및 트레이싱을 가능하게 합니다. 최신 프로덕션 프로파일러(Pyroscope, Parca, Datadog, Elastic)는 지속적인 상시 프로파일링을 위해 eBPF를 점점 더 많이 사용합니다.
커널은 호출 스택을 캡처하기 위한 두 가지 BPF 헬퍼 함수를 제공합니다:
bpf_get_stackid(ctx, map, flags)는 프레임 포인터 체인을 순회하고 스택 트레이스 매핑에 대한 해시 키를 반환합니다. 이는 eBPF 프로파일러, 트레이싱 도구 및 bpftrace 한 줄 명령에서 호출 스택을 캡처하는 표준 방법입니다. 이 함수는 프레임 포인터만 순회하며 그 외에는 아무것도 순회하지 않습니다: DWARF 폴백도, SFrame 폴백도, 대체 언와인딩 경로도 없습니다.bpf_get_stack(ctx, buf, size, flags)는 원시 프레임 주소를 호출자가 제공한 버퍼에 기록합니다.bpf_get_stackid()와 마찬가지로, 이 함수는 프레임 포인터 체인만 순회합니다.
두 헬퍼 함수는 엄격한 안전 제약을 적용하는 커널의 BPF 런타임 내부에서 실행됩니다. 이러한 제약에는 제한된 실행 시간, 무한 루프 금지, 임의 메모리 접근 금지 및 복잡한 라이브러리 코드 호출 금지가 포함됩니다. 이러한 제약으로 인해 범용 DWARF 언와인더를 BPF 헬퍼로 구현하는 것은 구조적으로 불가능합니다. DWARF 언와인딩에는 .eh_frame 섹션에서 가변 길이 명령어를 파싱하고, 스택 머신(DWARF Call Frame Information 상태 머신)을 평가하며, CIE/FDE 레코드의 임의로 깊은 체인을 따라가는 작업이 필요하지만, 이 중 어느 것도 BPF 검증기를 통과할 수 없습니다.
프레임 포인터가 없으면 bpf_get_stackid()와 bpf_get_stack()은 Python 프로세스에서 잘리거나 빈 결과를 생성합니다. 현재 프로덕션에서 사용되는 모든 eBPF 프로파일러(Pyroscope, Parca, Datadog의 연속 프로파일러, Elastic Universal Profiling, bpftrace, bcc의 profile.py)는 결국 이 두 헬퍼 함수 중 하나를 호출합니다. 이 함수들이 실패하면 프로파일러는 보고할 스택이 없습니다.
일부 공급업체(Polar Signals, Elastic, OpenTelemetry의 eBPF 프로파일러, Yandex의 Perforator [24])는 우회 방법으로 DWARF-in-eBPF를 구현했지만, 이 접근 방식은 상당히 느리고 복잡하며 커널에 내장된 스택 순회 헬퍼를 사용할 수 없습니다. 이러한 구현은 bpf_get_stackid()를 호출하는 대신 사용자 공간에서 .eh_frame 섹션을 파싱하고, 이를 압축된 스택 델타 조회 테이블로 변환하여 BPF 매핑에 로드한 다음, bpf_probe_read_user()로 스택 메모리를 수동으로 읽는 사용자 지정 BPF 프로그램에서 해당 테이블을 평가합니다. 이 방식에는 500줄 이상의 BPF 코드가 필요하고(프레임 포인터 방식은 50줄 미만임), 프로세스별로 디버그 정보를 파싱하고 로드하기 위한 시작 오버헤드가 발생하며, 스택 델타 테이블에 상당한 BPF 매핑 메모리를 사용합니다. 또한 bpftrace, bcc 또는 perf [12]와 같은 표준 도구에서는 사용할 수 없는 최첨단 공급업체별 인프라입니다.
대부분의 eBPF 사용 사례(bpftrace 한 줄 명령, bcc 도구, 프로덕션 모니터링용 사용자 지정 BPF 프로그램)에서 프레임 포인터는 유일하게 실행 가능한 언와인딩 메커니즘입니다. 커널에 내장된 헬퍼가 지원하는 유일한 메커니즘이기 때문입니다.
CPython 자체 문서에도 이미 권장되는 해결책이 명시되어 있습니다:
최상의 결과를 얻으려면 Python을CFLAGS="-fno-omit-frame-pointer -mno-omit-leaf-frame-pointer"로 컴파일해야 합니다. 이렇게 하면 프로파일러가 DWARF 디버그 정보가 아닌 프레임 포인터만 사용하여 언와인딩할 수 있습니다.
프로덕션 프로파일링 도구도 이 지침을 따르고 있습니다. Grafana Pyroscope의 문제 해결 문서에는 다음과 같이 명시되어 있습니다. “프로파일에 일반적으로 1~2개 프레임 깊이의 얕은 스택 트레이스가 많이 표시된다면, 바이너리가 프레임 포인터 없이 컴파일되었을 수 있습니다” [25].
권장 구성이 -fno-omit-frame-pointer라면, 이것이 기본값이어야 합니다.
Perf 트램펄린 기능에는 프레임 포인터가 필요합니다
Python 3.12에는 -Xperf (sys.activate_stack_trampoline)가 도입되었습니다. 이 기능은 Python 함수 이름을 perf에 표시할 수 있도록 작은 JIT 스텁을 생성합니다. 이러한 스텁에는 DWARF 정보가 없습니다. 프로파일러가 스텁을 통과하여 순회할 수 있는 유일한 방법은 프레임 포인터 체인을 이용하는 것입니다. 따라서 3.12에 제공된 -Xperf 기능은 -fno-omit-frame-pointer로 명시적으로 다시 빌드되지 않은 모든 설치에서 손상된 스택을 생성합니다.
Python 3.13에는 DWARF 기반 대안인 -Xperf_jit가 추가되었지만, 이 기능은 perf >= 6.8을 필요로 하고 프레임 포인터 방식보다 훨씬 큰 데이터 파일을 생성하며, 샘플별 오버헤드 때문에 지속적인 프로덕션 프로파일링에는 적합하지 않습니다.
배포판은 업스트림을 기다리고 있습니다
Fedora 38 [2], Ubuntu 24.04 LTS [3] 및 Arch Linux는 모두 전체 패키지 트리를 -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer로 다시 빌드했습니다. 그러나 Ubuntu 24.04 LTS는 CPython을 명시적으로 예외 처리했습니다:
영향이 큰 경우(예: Python 인터프리터)에는 이 문제가 해결될 때까지 프레임 포인터를 계속 생략할 것입니다.
그 결과는 순환 의존성입니다. 프로파일링을 위해 프레임 포인터가 가장 필요한 런타임에 주요 배포판이 업스트림 수정이 이루어질 때까지 프레임 포인터를 제공하지 않기 때문입니다. Red Hat Enterprise Linux와 CentOS Stream도 기본적으로 프레임 포인터를 비활성화하고, 대신 아직 프로덕션에 사용할 준비가 되지 않은 대체 접근법(eu-stacktrace, SFrame)에 투자합니다(Alternatives to Frame-Pointer Unwinding을 참조하십시오). python.org에서 Python을 설치하거나, 소스에서 빌드하거나, pyenv를 사용하거나, 시스템 전체에 프레임 포인터를 도입하지 않은 Debian, RHEL, openSUSE 또는 기타 배포판에서 작업하는 사용자는 자신의 로컬 배포판이 무엇을 하든 프레임 포인터를 사용할 수 없습니다. 업스트림 기본값을 설정하면 이 문제가 영구적으로 해결됩니다.
혼합 Python/C 프로파일링에는 연속적인 체인이 필요합니다.
실제 Python 애플리케이션은 C 확장 모듈에서 상당한 시간을 사용합니다. 예를 들면 NumPy, 암호화 라이브러리, 압축 라이브러리, 데이터베이스 드라이버 등이 있습니다. perf 플레임 그래프가 Python 코드에서 C 확장 모듈을 거쳐 시스템 라이브러리로 이어지는 전체 경로를 표시하려면, 프레임 포인터 체인이 인터프리터 및 호출 스택의 모든 확장 모듈을 통해 끊김없이 이어져야 합니다. _PyEval_EvalFrameDefault에서든 C 확장에서든 어느 지점에서든 단절이 발생하면 전체 프로세스의 체인이 끊어집니다.
연속적인 체인이 필요한 이유가 바로 플래그를 확장 빌드로 전파해야 하는 이유입니다. 인터프리터에만 프레임 포인터가 있고 확장에는 없으면, 모든 C 확장 경계에서 체인이 여전히 끊어집니다. CFLAGS에 sysconfig에서 보고하는 플래그를 추가하면, CPython의 컴파일러 플래그를 사용하는 확장 빌드(예를 들어 pip install, Setuptools 또는 기타 빌드 백엔드를 통한 빌드)는 기본적으로 프레임 포인터를 상속합니다. 독립적인 빌드 시스템을 사용하는 확장과 라이브러리는 프레임 포인터 체인이 연속적으로 유지되도록 동일한 플래그를 자체적으로 활성화해야 합니다.
JIT 컴파일러를 디버깅 가능하게 만들려면 프레임 포인터가 필요합니다.
CPython의 copy-and-patch JIT (PEP 744)는 런타임에 네이티브 기계어 코드를 생성합니다. JIT 코드에 예약된 프레임 포인터가 없으면, 생태계의 거의 모든 도구에서 JIT 프레임을 통한 스택 언와인딩이 작동하지 않습니다. GDB, LLDB, libunwind, libdw (elfutils), py-spy, Austin, pystack, memray, perf 및 모든 eBPF 기반 프로파일러가 여기에 포함됩니다. JIT로 컴파일된 코드에 대해 전체 스택 가시성을 보장하는 것은 JIT를 프로덕션에 사용할 준비가 되었다고 간주하기 위한 전제 조건입니다.
개별 JIT 스텐실에는 프레임 포인터 프롤로그가 필요하지 않습니다. 언와인딩 목적에서는 전체 JIT 영역을 하나의 프레임 없는 영역으로 취급할 수 있습니다. 중요한 점은 JIT 자체가 프레임 포인터를 예약하여 프레임 포인터 레지스터(%rbp는 x86-64에서, x29는 AArch64에서)가 예약되고 스텐실 코드에 의해 덮어써지지 않도록 해야 한다는 것입니다. JIT에 프레임 포인터가 있으면 대부분의 언와인더는 개별 스텐실을 검사하지 않고도 JIT 영역을 통과해 이동할 수 있습니다. 이는 사용자 지정 DWARF .eh_frame 생성, GDB JIT 인터페이스 지원(__jit_debug_register_code) 및 언와인더별 등록 API(_U_dyn_register, __register_frame)를 구현하는 데 일반적으로 수백 줄에서 수천 줄의 코드가 필요한 다른 JIT 컴파일러(V8, LuaJIT, .NET CoreCLR, Julia, LLVM의 ORC JIT)와 비교하면 놀라울 정도로 좋은 결과입니다. 프레임 포인터와 JIT에 대한 추가 논의는 issue #126910을 참조하십시오.
생태계는 이미 프레임 포인터를 도입했습니다.
프레임 포인터로 전환하는 움직임은 CPython 업스트림과 독립적으로, 그리고 대규모로 이미 일어났습니다.
uv, mise, rye 및 많은 CI 시스템에서 사용하는 격리형 Python 배포판인 python-build-standalone은 2026년 초 모든 x86-64 및 AArch64 Linux 빌드에서 -fno-omit-frame-pointer를 활성화했으며, uv 0.11.0에 포함되어 배포되었습니다 [13]. 이 프로젝트의 제작자인 Gregory Szorc는 다음과 같이 말했습니다. “2026년에는 x86-64 / aarch64 바이너리의 100%에서 프레임 포인터를 활성화해야 합니다. 단호하게 말해 그렇습니다.” 그는 이어서 다음과 같이 주장했습니다. “PBS에서 프레임 포인터를 활성화하는 데 그쳐서는 안 됩니다. CPython이 코어 인터프리터뿐만 아니라 컴파일된 C 확장에서도 기본적으로 프레임 포인터를 활성화하도록 촉구해야 합니다.” [13]
이는 많은 CI 시스템에서 uv python install을 사용하거나, Astral의 GitHub Actions를 사용하거나, python-build-standalone 바이너리를 가져오는 도구를 사용하는 등, Python 사용자의 크고 계속 증가하는 비율이 이미 프레임 포인터가 활성화된 Python을 실행하고 있음을 의미합니다. 이들이 매일 사용하는 인터프리터에는 이미 프레임 포인터가 활성화되어 있으며, 이 PEP는 업스트림 기본값을 그러한 현실에 맞춥니다.
python-build-standalone 벤치마크에서는 Python 3.11부터 3.15까지 1~3%의 오버헤드가 측정되었으며, 테일 콜이 아닌 빌드에서는 1.1%, 테일 콜 인터프리터에서는 최대 3.3%였습니다 [13]. 이러한 수치는 이 PEP의 자체 측정 결과 및 Fedora/Ubuntu 데이터와 일치합니다.
주요 Linux 배포판(Fedora 38 [2], Ubuntu 24.04 [3], Arch Linux)은 전체 패키지 트리를 프레임 포인터를 사용하도록 다시 빌드했습니다. PyTorch, Node.js, Redis, Go, .NET은 모두 기본 빌드에서 프레임 포인터를 채택했습니다(전체 목록은 Rationale의 Industry Consensus Has Shifted Decisively를 참조하십시오).
업스트림 기본값은 생태계가 이미 채택한 현실에 맞게 CPython을 정렬합니다.
명세
빌드 시스템 변경 사항
다음 변경 사항이 configure.ac에 적용됩니다.:
AX_CHECK_COMPILE_FLAG([-fno-omit-frame-pointer],
[BASECFLAGS="$BASECFLAGS -fno-omit-frame-pointer"])
AX_CHECK_COMPILE_FLAG([-mno-omit-leaf-frame-pointer],
[BASECFLAGS="$BASECFLAGS -mno-omit-leaf-frame-pointer"])
플래그는 CFLAGS_NODIST가 아니라 BASECFLAGS 앞에 추가되므로 sysconfig를 통해 서드파티 빌드로 전파됩니다. 이를 통해 다음이 보장됩니다.
- 플래그는 인터프리터의 일부로 컴파일되는 모든
*.c파일, 즉python바이너리,libpython및Modules/아래의 내장 확장 모듈에 적용됩니다. - 플래그는
sysconfig데이터에 기록되므로, 이 Python을 기반으로 빌드되는 서드파티 C 확장(pip, Setuptools 또는 직접적인sysconfig조회를 통해)이 기본적으로 프레임 포인터를 상속합니다.
여러 아키텍처에서는 순회 가능한 프레임 포인터 체인을 생성하기 위한 조정이 필요합니다.
- 32비트 ARM에서는 ARM 모드를 강제하기 위해
-marm(GCC) 또는-mno-thumb(Clang)이 추가됩니다. GCC의 기본 Thumb 프롤로그는 단순한 언와인더가 예상하는fp[0]/fp[1]레이아웃을 보존하지 않기 때문입니다. - s390x에서는 프레임 포인터 플래그 대신
-mbackchain이 추가됩니다. GCC와 Clang은 이 플래그 없이는 s390x에서 사용할 수 있는 백체인을 생성하지 않습니다. - ppc64le에서는 컴파일러 플래그를 추가하지 않습니다. Power ABI는 이미 컴파일러가 기본적으로 백체인을 유지하도록 요구하므로
-fno-omit-frame-pointer없이도 언와인딩이 작동합니다.
이는 의도적인 설계 선택입니다. 프로파일링 데이터가 유용하려면 프레임 포인터 체인이 전체 호출 스택을 따라 끊김 없이 이어져야 합니다. C 확장 경계에서 발생하는 어떠한 단절도 인터프리터 자체에서 발생하는 단절만큼 해롭습니다. 플래그를 전파함으로써 CPython은 Python 스택에 대한 생태계 전반의 기본값으로 프레임 포인터를 확립합니다.
-mno-omit-leaf-frame-pointer는 리프 함수에서도 프레임 포인터를 보존합니다. 이 플래그가 없으면 -fno-omit-frame-pointer가 설정되어 있더라도 컴파일러는 추가 호출을 수행하지 않는 모든 함수에서 프레임 포인터를 제거할 수 있습니다. Fedora, Ubuntu 및 Arch Linux는 모두 이 플래그를 포함합니다. 따라서 리프 함수 내부에서 프로파일러가 샘플링하더라도 완전한 호출 체인을 복구할 수 있습니다.
옵트아웃 Configure 플래그
새로운 configure 옵션이 추가됩니다.:
--without-frame-pointers
이 옵션을 지정하면 어느 플래그도 BASECFLAGS에 추가되지 않습니다. 이는 특정 워크로드에서 허용할 수 없는 성능 저하를 측정한 배포 환경이나, 더 높은 수준에서 프레임 포인터 플래그를 주입하여 중복 지정을 피하려는 배포판에 적합합니다. Fedora의 패키지별 %undefine _include_frame_pointers 매크로와 유사합니다.
특정 모듈에 대한 기본값을 재정의하려는 확장 작성자는 extra_compile_args에 -fomit-frame-pointer를 전달하거나 환경 변수를 통해 지정할 수 있습니다. GCC와 Clang에서는 명령줄의 마지막 플래그가 우선합니다.
생태계 영향
플래그가 CFLAGS에 포함되어 있으므로, pip, Setuptools 또는 직접적인 sysconfig조회를 통해 빌드되는 C 확장과 같이 CPython이 보고하는 컴파일러 플래그를 기반으로 빌드하는 소비자에게 자동으로 전파됩니다. 이러한 소비자는 이 변경의 혜택을 받기 위해 추가 조치를 취할 필요가 없습니다.
Python 생태계의 모든 컴파일된 코드가 CPython의 CFLAGS를 상속하는 것은 아닙니다. pyo3 또는 maturin으로 빌드되는 Rust 확장, 자체 빌드 시스템을 사용하는 C++ 라이브러리, 그리고 소스에서 CPython을 컴파일하는 임베딩 애플리케이션은 각각 자체 컴파일러 플래그를 관리합니다. 이 PEP는 이러한 모든 프로젝트가 빌드에서 -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer도 활성화할 것을 권장합니다. 프레임 포인터 체인은 가장 약한 연결 고리만큼만 강합니다. 호출 스택에 프레임 포인터가 없는 라이브러리가 하나라도 있으면 CPython과 다른 모든 라이브러리에 프레임 포인터가 있는지 여부와 관계없이 전체 프로세스의 체인이 끊어집니다. 목표는 Python 프로세스의 모든 네이티브 구성 요소가 프레임 포인터 체인에 참여하도록 하는 것입니다. 이를 통해 perf record와 eBPF 프로파일러가 별도의 설정 없이 완전하고 유용한 플레임 그래프를 생성할 수 있습니다.
특정 모듈에서 허용할 수 없는 회귀를 관찰한 확장 기능 작성자는 Extension Build Impact를 참조하여 extra_compile_args를 통해 확장 기능별로 제외할 수 있습니다. 시스템 전체에서 이미 프레임 포인터를 활성화한 배포판(Fedora, Ubuntu, Arch Linux)은 아무 조치도 취할 필요가 없습니다.
문서 업데이트
Doc/howto/perf_profiling.rst는 Python 3.15부터 프레임 포인터가 기본적으로 활성화된다는 점을 명시하고, 이전 버전에 대한 CFLAGS 권고를 유지하도록 업데이트됩니다.
Doc/using/configure.rst는 --without-frame-pointers를 문서화하도록 업데이트됩니다.
플랫폼 범위
두 플래그는 x86-64, AArch64, RISC-V 및 32비트 ARM에서 GCC와 Clang이 모두 허용합니다. s390x와 ppc64le은 다른 처리가 필요합니다(위 참조). Apple Silicon을 사용하는 macOS에서는 ARM64 ABI가 프레임 포인터를 요구합니다. 따라서 이 플래그들은 중복되지만 무해합니다.
Windows x64에서 MSVC는 스택 언와인딩에 프레임 포인터를 사용하지 않습니다. 대신 Windows x64 ABI는 모든 비리프 함수에 대해 .pdata / .xdata 언와인드 메타데이터를 요구합니다 [18]: 컴파일러는 각 함수의 프롤로그가 스택을 어떻게 변경하는지 설명하는 RUNTIME_FUNCTION 및 UNWIND_INFO 구조체를 생성하므로, OS 언와인더는 프레임 포인터 체인 없이 RSP와 정적으로 알려진 프레임 크기를 사용하여 스택을 순회할 수 있습니다. 이 메타데이터는 항상 존재하고 항상 정확하므로 Windows x64의 프로파일러, 디버거 및 ETW 기반 추적은 프레임 포인터 없이도 이미 신뢰할 수 있는 호출 스택을 생성합니다. /Oy(프레임 포인터 생략) 플래그는 32비트 x86 MSVC 대상에서만 사용할 수 있으며, x64에는 존재하지 않습니다 [19]. 이 PEP에서 제안하는 GCC/Clang 플래그는 MSVC 빌드에 영향을 주지 않습니다.
Windows ARM64에서는 ETW 기반 고속 스택 순회와의 호환성을 위해 ABI가 프레임 포인터(x29)를 요구합니다 [20]. 프레임 포인터는 기본적으로 활성화되므로 아무 조치도 필요하지 않습니다.
AX_CHECK_COMPILE_FLAG 가드는 컴파일러가 허용하지 않는 플래그를 자동으로 건너뛰므로, 모든 플랫폼과 도구 모음에서 이 변경을 안전하게 적용할 수 있습니다.
근거
프레임 포인터는 비용이 낮고 가치가 높은 기본값입니다.
프레임 포인터를 생략하는 근거(범용 레지스터 또는 GPR 하나를 확보하는 것)는 32비트 x86에서 의미가 있었습니다. 이 환경에서 %ebp는 사용 가능한 레지스터를 약 20% 증가시켰기 때문입니다(5개에서 6개로 증가). x86-64에서는 증가폭이 7% 미만이고(레지스터 15개에서 16개), 31개의 GPR을 가진 AArch64에서는 무시할 수 있는 수준입니다.
실제 배포 환경에서의 경험적 측정 결과도 일관됩니다.
- Brendan Gregg(OpenAI, سابق Netflix): “Java와 glibc에서 대규모로 프레임 포인터를 활성화했습니다… 일반적으로 1% 미만이며, 대개 측정하기 어려울 정도로 0에 가깝습니다.” [1]
- Meta: 가장 성능에 민감한 두 애플리케이션에 대한 내부 벤치마크에서는 “성능에 유의미한 영향이 나타나지 않았습니다.” 이는 Daan De Meyer, Davide Cavalca 및 Andrii Nakryiko(Meta/Facebook 소속)가 작성한 Fedora 38 변경 제안에 보고되어 있습니다 [2]. Google도 마찬가지로 모든 내부 핵심 소프트웨어를 프레임 포인터를 사용하여 컴파일합니다 [2].
- Ubuntu 24.04 분석: “64비트 아키텍처에서의 손실은 대부분의 경우 1~2% 사이입니다.” [3]
- Fedora 38 테스트 모음: 개별 벤치마크 회귀는 약 2%입니다(커널 컴파일 2.4%, Blender 렌더링 2%) [2].
pyperformance의 scimark_sparse_mat_mult벤치마크는 Fedora 테스트에서 9.5% 회귀했으며, 이는 해당 실행에서 최악의 사례였습니다(아래의 Detailed Performance Analysis of CPython with Frame Pointers 참조). CPython 3.15-dev에 대한 자체 측정에서는 xml_etree_*벤치마크에서 더 큰 개별 회귀가 나타났지만(최대 1.31배), 기하 평균은 여전히 약 1%입니다. 이러한 최악의 사례 벤치마크는 거의 전적으로 C 헬퍼 함수 호출을 실행합니다. 실제 애플리케이션은 인터프리터, C 확장, I/O 및 시스템 호출에 CPU 시간을 분산합니다.
커뮤니티에서 흔히 발생하는 오해는 “10% 느려진 단 하나의 Python 사례가 있었기 때문에” 프레임 포인터의 오버헤드가 크다는 것입니다 [5]. 해당 사례는 평가 루프 벤치마크이며, 실제 워크로드 전체의 기하 평균은 0.5~2.3%입니다.
프레임 포인터를 사용한 CPython의 상세 성능 분석
간단히 말해: 오버헤드는 두 빌드 모두에서 이미 프레임 포인터를 사용하는 평가 루프에서 발생하는 것이 아니라, 약 6,000개의 소규모 C 헬퍼 함수가 4바이트 프롤로그를 추가하고 GPR 하나를 잃는 데서 발생합니다. 측정된 비용은 C 호출이 많은 워크로드에서 명령어가 5.5% 증가하고 벽시계 시간이 3.3% 증가하는 것이며, 캐시 또는 분기 예측 이상은 나타나지 않았습니다.
오버헤드를 정확히 이해하기 위해 CPython 3.15-dev에서 바이너리 수준 및 마이크로아키텍처에 대한 통제된 분석을 수행했습니다. 두 빌드는 동일한 커밋, 동일한 컴파일러(GCC), 동일한 플래그(-O3 -DNDEBUG -g)를 사용했으며, 스케줄링 잡음을 제거하기 위해 동일한 머신(x86-64, Intel)의 단일 P 코어에 고정하여 실행했습니다. 유일한 차이는 -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer의 사용 여부였습니다.
흔한 오해는 프레임 포인터 오버헤드가 주로 바이트코드 디스패치 함수인 _PyEval_EvalFrameDefault에서 발생한다는 것입니다. Meta의 BPF 커널 유지 관리자인 Andrii Nakryiko는 Python 3.11에서 회귀를 분석했으며, 프레임 포인터를 사용하면 해당 함수가 크게 증가한다는 사실을 발견했습니다 [4]. 그러나 현재 코드베이스(DSL로 생성된 평가 루프를 사용하는 3.15-dev)에서는 더 이상 그렇지 않습니다. 함수가 깊게 중첩된 약 60KB 크기이므로 GCC는 이미 both 빌드에서 _PyEval_EvalFrameDefault에 대한 프레임 포인터 프롤로그를 생성하며, 컴파일러는 플래그와 관계없이 %rbp를 프레임 포인터로 유지합니다. 해당 함수의 크기는 플래그를 사용한 경우 59,549바이트이고 사용하지 않은 경우 59,602바이트입니다(차이는 0.1%입니다). 핫 바이트코드 디스패치 핸들러(STORE_FAST, LOAD_FAST 등)는 두 빌드에서 명령어 단위로 완전히 동일합니다. 평가 루프가 지배적인 워크로드는 프레임 포인터를 사용하면 실제로 1~2% 더 빠릅니다. 서로 다른 레지스터 할당이 명령어 페치 스톨을 줄이는 코드 배치를 생성하기 때문입니다(프론트엔드 바운드 비율은 24.1%에서 19.4%로 감소하고 IPC는 4.83에서 5.06으로 향상됩니다).
오버헤드는 대신 프레임 포인터 프롤로그(push %rbp; mov %rsp,%rbp를 진입 시, pop %rbp를 종료 시)에 의해 영향을 받는 약 6,000개의 작은 C 헬퍼 함수에서 발생합니다. 기준선 빌드에서는 7,471개의 텍스트 심볼 중 84개만 프레임 포인터 프롤로그를 가집니다(1.1%). -fno-omit-frame-pointer를 사용하면 6,009개가 프롤로그를 가집니다(80.4%). 또한 각 함수는 범용 레지스터로서 %rbp를 사용할 수 없게 되므로, 컴파일러는 값을 다른 레지스터로 이동하거나 스택에 저장해야 합니다. 예를 들어 insertdict는 CPython에서 가장 핫한 C 함수 중 하나이며 딕셔너리 중심 워크로드에서 사이클의 약 20%를 차지합니다. 두 빌드에서 코드 크기는 동일하지만(1,055바이트), 기준선 빌드에서는 %rbp가 함수 인자를 직접 보유하여 비용이 0인 반면, 프레임 포인터 빌드에서는 해당 인자를 %r12로, 키 인자를 %r13로 이동해야 하며, 이동된 값을 수용하기 위해 스택 프레임이 40바이트에서 56바이트로 증가합니다.
반복마다 약 10개의 C 헬퍼 함수(insertdict, _Py_dict_lookup, unicodekeys_lookup_unicode, PyDict_SetItem 등)를 호출하는 긴밀한 루프 전체에서 프롤로그/에필로그 명령어와 레지스터 스필이 누적되어 동적 명령어가 5.5% 더 증가합니다(1억 회 반복에서 1675억 개 대 1588억 개). 이러한 명령어 수 증가는 C 함수 호출이 많은 워크로드에서 측정된 벽시계 시간 오버헤드 3.3%를 직접 설명합니다.
더 느린 C 호출 중심 워크로드에 대한 Intel Top-Down Method 분석은 오버헤드가 전적으로 추가 작업에 의한 것이며, 작업이 비효율적으로 실행되기 때문이 아님을 확인합니다:
| 메트릭 | FP 빌드 | 기준선 | 차이 |
|---|---|---|---|
| 벽시계 시간 | 10.27초 | 9.94초 | +3.3% |
| 명령어 | 167.5B | 158.8B | +5.5% |
| IPC | 5.11 | 5.00 | +2.1% |
| Retiring (유용한 작업) | 78.1% | 75.5% | +2.6 pp |
| 프런트엔드 바운드 | 20.4% | 23.1% | -2.7 pp |
| 백엔드 바운드 | 1.1% | 0.9% | +0.2 pp |
| L1 데이터 캐시 로드 미스 | 2.02 M | 2.08 M | -3.1% |
| L1 명령어 캐시 로드 미스 | 4.87 M | 4.91 M | -0.9% |
| 분기 미스 | 473 K | 471 K | +0.4% |
프레임 포인터를 사용하면 retiring 비율이 더 높고 (CPU가 유용한 작업을 수행하는 데 더 많은 시간을 사용하고), 프런트엔드 바운드 비율은 더 낮으며, 캐시 미스율은 비슷하거나 근소하게 개선됩니다. 추가적인 스택 스필/리로드 트래픽으로 인해 전체 L1 데이터 캐시 로드 작업이 약 3% 증가하지만(위 표에는 표시되지 않았으며, 위 표는 L1 로드 미스만 보고합니다), 이러한 추가 로드는 모두 L1에서 적중합니다. 캐시 폭주는 없고, TLB 압력 증가도 없으며, 분기 예측 이상도 없습니다. 오버헤드는 예측 가능하고 제한적이며, 다른 하드웨어나 워크로드에서 예기치 않은 회귀가 발생할 위험도 없습니다.
프레임 포인터 빌드에서는 .text섹션이 0.5% 더 작습니다 (5,658,071바이트 대 5,686,703바이트). %rbp-상대 주소 지정이 %rsp + SIB 주소 지정보다 더 간결한 인코딩을 생성하는 경우가 많고, 더 단순한 에필로그(pop 체인 대 add $N,%rsp)도 바이트를 절약하기 때문입니다. 바이너리 크기는 문제가 되지 않습니다.
마지막으로, CPython의 구조적 추세는 프레임 포인터를 선호합니다. CPython 3.11의 특수화 적응형 인터프리터, 3.12의 DSL 생성 평가 루프, 그리고 실험적인 copy-and-patch JIT (PEP 744, 3.13에서 도입됨)는 핫 실행을 일반적인 C 헬퍼 경로에서 점진적으로 벗어나게 합니다. 더 많은 바이트코드가 특수화된 코드 또는 JIT 컴파일된 코드로 처리됨에 따라 이러한 헬퍼 함수에서 소비되는 시간의 비율이 감소하고, 그에 따라 프레임 포인터 오버헤드도 감소합니다.
업계의 합의가 결정적으로 바뀌었습니다
기본적으로 프레임 포인터를 생략하기로 한 결정은 2000년대 초로 거슬러 올라가며, 2011년 GCC 4.6에서 공식화되었습니다. 이후 업계는 대체로 방향을 되돌렸습니다:
- Go 1.7 (2016): x86-64에서 프레임 포인터가 기본적으로 활성화되었습니다 [6]. Russ Cox: “프레임 포인터를 기본적으로 활성화하면 Linux perf, Intel VTune 및 기타 프로파일러가 Go 스택 트레이스를 훨씬 효율적으로 가져올 수 있습니다” [7]. Go 팀은 관측 가능성을 위해 명시적으로 약 2%의 오버헤드를 감수했습니다.
- Rust 표준 라이브러리 (2024): PR #122646은 공식 Rust 툴체인과 함께 제공되는 사전 컴파일된 표준 라이브러리에서 프레임 포인터를 활성화했으며, 명령어 수는 측정 결과 0.3% 회귀했지만 사이클 수 회귀는 없었습니다 [21].
- Chromium: GN 빌드 시스템은 세계 최대 규모의 C++ 프로젝트 중 하나인 Chromium의 모든 데스크톱 Linux 및 macOS 빌드에서
enable_frame_pointers를 기본적으로true로 설정합니다 [22]. - .NET CoreCLR: 처음부터 Linux 및 macOS x64에서 프레임 포인터가 기본적으로 활성화되어 네이티브 OS 스택 언와인딩 도구를 지원합니다 [16].
- Node.js: 2013년부터 C++ 런타임을
-fno-omit-frame-pointer로 컴파일해 왔으며, 2022년 9월 플래그를 제거하려던 시도는 C++/JS 전환 지점에서perf프로파일링이 중단되어 되돌려졌습니다 [8]. - PyTorch: AArch64 빌드에서
-fno-omit-frame-pointer를 무조건 활성화하며, “aarch64 C++ 스택 언와인딩은 프레임 포인터 체인 순회를 사용하므로 모든 빌드 유형에 프레임 포인터가 있어야 합니다”라고 설명합니다 [9]. - Redis: Fedora 38에 이어
-fno-omit-frame-pointer를 도입하면서 “프레임 포인터를 사용해 빌드해도 Redis 벤치마크에는 유의미한 영향이 없는 것으로 보입니다”라고 밝혔습니다 [10] - Fedora 38 [2], Ubuntu 24.04 [3], Arch Linux, AlmaLinux Kitten 10 [23]: 모든 시스템 패키지가 프레임 포인터를 사용하도록 다시 빌드되었습니다(Ubuntu의 CPython은 제외). AlmaLinux는 프레임 포인터를 비활성화하는 RHEL/CentOS Stream과 명시적으로 다른 방향을 취했습니다.
- python-build-standalone: 2026년 초부터 모든 x86-64 및 AArch64 Linux 빌드에 프레임 포인터가 포함되어 제공됩니다 [13].
CPython은 아직 이 변경을 채택하지 않았습니다.
BASECFLAGS 대신 CFLAGS_NODIST를 사용하지 않는 이유
CPython의 빌드 시스템은 인터프리터에 적용하되 sysconfig를 통해 확장 모듈 빌드로 전파되지 않아야 하는 플래그를 위해 특별히 CFLAGS_NODIST를 제공합니다. CFLAGS_NODIST를 사용하면 오버헤드가 인터프리터 자체로 제한됩니다.
이 PEP는 프레임 포인터가 체인이 연속적일 때만 유용하기 때문에 CFLAGS_NODIST보다 BASECFLAGS를 의도적으로 선택합니다. 단일 구성 요소에 적용해도 유용한 새니타이저나 어설션 같은 디버깅 보조 기능과 달리, C 확장 경계에 단절이 있는 프레임 포인터 체인은 프레임 포인터가 전혀 없는 경우와 동일하게 손상된 스택 트레이스를 생성합니다. 프로파일러, 디버거 또는 eBPF 도구는 그 단절을 건너뛰고 언와인딩을 재개할 수 없습니다.
이는 프레임 포인터를 CFLAGS_NODIST에 배치되는 다른 플래그와 구별합니다. 이러한 플래그(예: -Werror 또는 내부 경고 억제)는 컴파일 단위별로 의미가 있는 정확성 또는 정책 제어 수단입니다. 프레임 포인터는 모든 참여자가 협력할 때만 효과가 있는 생태계 전체의 속성입니다. CPython에서 측정된 0.5~2%의 오버헤드는 작은 C 헬퍼 함수 호출의 높은 밀도에서 발생하며, 일반적인 C 확장 코드는 동일한 호출 밀도를 보이지 않으므로 오버헤드가 무시할 수 있을 정도입니다.
python-build-standalone 제작자인 Gregory Szorc가 지적했듯이, “컴파일된 확장 기능에 프레임 포인터가 포함되는 긴 꼬리 문제를 넘어서기까지는 수년이 걸릴 것입니다. 따라서 더 일찍 시작할수록…” [13] BASECFLAGS를 통해 플래그를 전파하는 것이 CPython이 그 과정을 시작하는 방법입니다.
프레임 포인터 언와인딩의 대안
프레임 포인터 기반 언와인딩을 대체할 몇 가지 방법이 있지만, 현재로서는 어느 것도 충분한 대안이 아닙니다.
DWARF CFI (perf --call-graph dwarf)는 Profilers Are Slower and Less Accurate Without Frame Pointers에서 논의되며, 샘플마다 스택 8KB를 복사하고 훨씬 큰 데이터 파일을 생성하며 BPF 컨텍스트에서 사용할 수 없습니다 [11].
Intel LBR(Last Branch Record)은 16~32개 프레임으로 제한되고 Intel 하드웨어가 필요하며 가상 머신과 클라우드 환경에서는 사용할 수 없습니다 [17].
DWARF-in-eBPF(Polar Signals, Elastic Universal Profiling, OpenTelemetry의 eBPF 프로파일러, Yandex Perforator [24])는 커널에 내장된 bpf_get_stackid() / bpf_get_stack() 헬퍼를 완전히 우회하고 BPF 내부에서 스택 워킹을 처음부터 다시 구현합니다. 이 방식은 더 느리고(각 샘플마다 단일 헬퍼 호출 대신 여러 bpf_probe_read_user() 호출과 BPF 맵 조회가 필요합니다), 더 복잡하며(프레임 포인터 경로의 50줄 미만에 비해 BPF 코드가 500줄 이상입니다), 프로세스별로 시작할 때 .eh_frame을 구문 분석하고 스택 델타 테이블을 BPF 맵에 적재하는 오버헤드가 필요합니다. 이는 bpftrace, bcc, perf 또는 표준 도구에서 사용할 수 없는 벤더별 인프라입니다 [12].
SFrame(Simple Frame format)은 경량 스택 언와인딩 형식으로, 6.3 버전(2023년 4월)에 Linux 커널에 병합되었으며 .sframe 섹션을 생성하기 위한 GNU binutils의 지원을 받습니다. 이는 프레임 포인터 없이 BPF 기반 언와인딩에 사용할 수 있을 만큼 단순하도록 설계되었습니다. 그러나 2026년 초 현재 bpf_get_stackid()는 SFrame을 지원하지 않고, 프로덕션에 바로 사용할 수 있는 프로파일링 도구 체인 중 이를 사용하는 것은 없으며, perf에는 SFrame 기반 호출 그래프 모드가 없고, 사용자 공간 도구(GDB, libunwind, libdw)의 SFrame 지원은 없거나 실험적입니다. Brendan Gregg는 SFrame 생태계의 성숙 시점을 2029년경으로 추정했습니다 [1]. 그 사이 Python 배포가 이루어지는 기간을 사용 가능한 시스템 수준 프로파일링 없이 방치해서는 안 됩니다. SFrame이 생태계 동등성을 달성하면 이 결정을 재검토할 수 있습니다.
프레임 포인터 언와인딩은 커널 측에서 실행되고, 비동기 시그널에 안전하며, BPF와 호환되고, 깊이 제한이 없으며, 압축된 프로파일링 데이터를 생성하는 모든 조건을 동시에 만족하는 유일한 방법으로 남아 있습니다. 추가 구성 없이 어디서나 작동하는 방법입니다.
하위 호환성
바이너리 호환성
프레임 포인터를 활성화해도 Python ABI는 변경되지 않습니다. 안정 ABI(PEP 384), 제한된 C API, PyObject메모리 레이아웃은 모두 영향을 받지 않습니다. 컴파일된 확장 모듈은 로드에 실패하거나 잘못 작동하지 않습니다.
성능
프레임 포인터를 사용하는 빌드와 프레임 포인터가 없는 동일한 빌드를 비교한 전체 pyperformance 결과입니다(80개 벤치마크의 기하 평균 [30]). 재현성을 위해 pyperformance JSON 파일은 [29]에서 찾을 수 있습니다. 벤치마크 시각화는 부록에서 확인할 수 있습니다:
| 머신 | 기하 평균 성능 저하 |
|---|---|
| Apple M2 Mac Mini(arm64) | 0.5% |
| macOS M3 Pro(arm64) | 0.1% |
| Raspberry Pi(aarch64) | 0.2% |
| Ampere Altra Max(aarch64) | 1.5% |
| AWS Graviton c7g.16xlarge (aarch64) | 2.3% |
| Intel i7 12700H (x86-64) | 1.9% |
| AMD EPYC 9654 (x86-64) | 1.8% |
| Intel Xeon Platinum 8480 (x86-64) | 1.5% |
이 오버헤드는 인터프리터와 sysconfig를 통해 플래그를 상속하는 C 확장 기능 모두에 적용됩니다. 상세한 마이크로아키텍처 분석에 따르면 이 오버헤드는 추가 명령어(약 6,000개 보조 함수의 프레임 포인터 프롤로그)에서만 발생하며, 비정상적인 캐시, TLB 또는 분기 예측 영향은 없습니다(Detailed Performance Analysis of CPython with Frame Pointers 참조). 일반적인 C 확장 기능 코드는 CPython 런타임과 같은 소규모 함수 호출 밀도를 보이지 않습니다. 수치 집약적인 확장 기능(NumPy, SciPy)은 일반적으로 BLAS/LAPACK 또는 별도로 컴파일되어 Python의 CFLAGS의 영향을 받지 않는 벡터화된 인트린식에서 핫 루프를 실행합니다. 핫 스칼라 C 루프가 있는 확장 기능(예: Cython으로 생성된 코드)에서는 측정 가능하지만 작은 오버헤드가 발생할 수 있습니다.
참고로 0.5-2.3%의 기하 평균은 -fstack-protector-strong(보안)과 공유 라이브러리용 ASLR 호환 -fPIC 플래그처럼 빌드 시 기본값으로 일반적으로 수용되는 오버헤드와 비슷합니다. 그 대가로 전체 Python 생태계는 완전한 플레임 그래프, 정확한 프로파일러 출력 및 신뢰할 수 있는 디버거 백트레이스를 생성할 수 있게 되며, 현재 대부분의 Python 설치에서 손상되었거나 사용할 수 없는 기능을 확보하게 됩니다. 이는 적은 비용에 비해 상당한 효과입니다. 이 비용조차 수용할 수 없는 배포 환경에서는 --without-frame-pointers를 사용할 수 있습니다.
확장 기능 빌드 영향
Python 3.15 이상에 대해 빌드된 C 확장 기능은 sysconfig에서 기본 CFLAGS로 -fno-omit-frame-pointer와 -mno-omit-leaf-frame-pointer를 상속합니다. 이는 확장 기능이 이미 -O2, 경고 플래그 및 기타 컴파일 기본값을 상속하는 것과 동일한 메커니즘입니다.
자체 CFLAGS를 설정하거나 setup.py / pyproject.toml에서 extra_compile_args를 사용하는 확장 기능은 이 기본값을 재정의할 수 있습니다. 명령줄의 마지막 플래그가 적용되므로 -fomit-frame-pointer를 덧붙이는 것만으로 확장 기능별로 제외할 수 있습니다.
빌드 재현성
결정론적 플래그는 결정론적 빌드 단계에 추가되므로, 이전에 재현 가능했던 빌드는 계속 재현 가능합니다.
보안 영향
이 변경 사항은 보안에 영향을 주지 않습니다. 프레임 포인터는 스택 프레임을 배치하기 위한 컴파일러 규약이며, 새로운 공격 표면을 만들거나 CPython의 기존 인터페이스를 통해 이미 이용 가능한 정보가 아닌 정보를 노출하지 않습니다.
이 내용을 가르치는 방법
Python 사용자와 애플리케이션 개발자에게 이 변경 사항은 보이지 않습니다. API가 변경되지 않고 동작도 변경되지 않으며 사용자 조치도 필요하지 않습니다. 관찰 가능한 유일한 효과는 프로파일러, 디버거 및 시스템 수준 추적 도구가 기본적으로 더 완전하고 신뢰할 수 있는 결과를 생성한다는 것입니다.
확장 기능에서는 오버헤드가 무시할 수 있는 수준이어야 하지만, 특정 모듈에서 측정 가능한 성능 저하를 확인한 확장 기능 작성자는 Extension Build Impact에 설명된 대로 제외할 수 있습니다. --without-frame-pointers구성 플래그는 Opt-Out Configure Flag에 설명되어 있습니다.
참조 구현
거부된 아이디어
이 PEP는 프레임 포인터를 배포별 옵트인으로 남겨 두는 방안을 거부합니다. 이는 Python 사용자, Linux 배포판 또는 다운스트림 도구에 신뢰할 수 있는 기본 관측 가능성 환경을 제공하지 못하기 때문입니다. 또한 DWARF 기반 또는 공급업체별 언와인딩 방식을 충분한 범용 솔루션으로 취급하는 방안도 거부합니다. 이러한 방식은 커널 지원 프로파일링 및 추적을 위해 동일한 낮은 오버헤드와 보편적으로 사용 가능한 스택 워킹 경로를 제공하지 못하기 때문입니다. Alternatives to Frame-Pointer Unwinding의 대안은 일부 상황에서 여전히 유용하지만, 기본 기준선으로서 프레임 포인터가 필요하다는 점을 없애지는 못합니다.
변경 이력
현재 없습니다.
각주
부록
아래의 모든 그래프에서 녹색 점은 개별 벤치마크 중앙값의 기하 평균이며, 주황색 선은 데이터 포인트의 중앙값입니다. 속이 빈 원은 이상치를 나타냅니다.
첫 번째 그래프는 각 시스템에서 확인된 pyperformance에 대한 전반적인 영향을 보여 줍니다. 모든 시스템 구성에서 기하평균과 중앙값 속도 저하가 2% 미만입니다:
개별 벤치마크 결과는 다음을 참조하십시오:
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.