[cpython] Python CPython: 함수 이름 속성 설정 시 Stop-the-World 방지로 멀티스레딩 성능 대폭 개선
PR 링크: python/cpython#159269 상태: Merged | 변경: +317 / -77
들어가며
Python의 함수 객체는 __name__, __qualname__, __doc__, __module__과 같은 다양한 메타데이터 속성을 가지고 있습니다. 이러한 속성들은 함수의 식별, 문서화, 모듈 정보 제공 등 중요한 역할을 합니다. 하지만 CPython의 멀티스레드 빌드(free-threaded build)에서는 이러한 속성들을 설정하는 과정에서 전역 인터프리터 잠금(Global Interpreter Lock, GIL)을 해제하지 않는 'Stop-the-World' (STW) 현상이 발생하여 심각한 성능 저하를 야기했습니다. 특히 functools.wraps와 같이 함수 속성을 빈번하게 다루는 경우, 여러 스레드에서 동시에 접근할 때 성능이 수십 배까지 느려지는 문제가 있었습니다.
이번 PR (gh-159136)은 이러한 문제를 해결하기 위해 함수 속성 설정 시 발생하는 STW를 제거하고, 스레드 안전성을 확보하여 멀티스레딩 환경에서의 성능을 획기적으로 개선하는 것을 목표로 합니다.
코드 분석
이번 PR의 핵심은 함수 객체의 특정 속성(__name__, __qualname__, __doc__, __module__)을 설정할 때 발생하는 동시성 문제를 해결하는 데 있습니다. 이전에는 이러한 속성들을 변경할 때 전역적인 잠금 메커니즘을 사용하여 다른 모든 스레드를 중지시켰지만, 이제는 각 객체별 잠금(per-object critical section)과 원자적 연산(atomic operations)을 사용하여 동시 접근을 안전하게 처리합니다.
1. Include/cpython/funcobject.h 변경사항
함수 객체의 func_module 및 func_annotations 필드에 접근하는 매크로들이 수정되었습니다. 특히 free-threaded 빌드(Py_GIL_DISABLED)에서 이러한 필드에 접근할 때 _Py_atomic_load_ptr_acquire 함수를 사용하여 원자적이고 순서 보장(acquire semantics)이 있는 로드를 수행하도록 변경되었습니다.
Before:
static inline PyObject* PyFunction_GET_MODULE(PyObject *func) {
return _PyFunction_CAST(func)->func_module;
}
After:
static inline PyObject* PyFunction_GET_MODULE(PyObject *func) {
#ifdef Py_GIL_DISABLED
return (PyObject *)_Py_atomic_load_ptr_acquire(
&_PyFunction_CAST(func)->func_module);
#else
return _PyFunction_CAST(func)->func_module;
#endif
}
PyFunction_GET_ANNOTATIONS 역시 유사하게 변경되었습니다. 이는 다른 스레드에서 해당 속성이 변경되는 동안에도 안전하게 값을 읽을 수 있도록 보장합니다. _Py_atomic_load_ptr_acquire는 다른 스레드에서 발생할 수 있는 쓰기 작업이 현재 스레드의 읽기 작업 이전에 완료되도록 보장하는 역할을 합니다. 리뷰어 nascheme는 FT_ATOMIC_ 매크로 대신 _Py_atomic_load_ptr*를 사용하는 것이 더 안전하다고 지적했으며, 이는 PR에서 채택되었습니다.
2. Modules/_testcapi/function.c 변경사항
C API 테스트를 위해 function_get_module_macro 함수가 추가되었습니다. 이는 PyFunction_GET_MODULE 매크로를 직접 사용하는 C API의 동작을 테스트하기 위함입니다. Py_XNewRef를 사용하여 반환된 객체의 참조 카운트를 증가시키는 것은, 다른 스레드에서 해당 모듈 속성이 변경되거나 해제될 수 있는 상황에서 안전하게 객체를 사용하기 위한 필수적인 조치입니다.
추가된 코드:
static PyObject *
function_get_module_macro(PyObject *self, PyObject *func)
{
return Py_XNewRef(PyFunction_GET_MODULE(func));
}
3. Lib/test/test_free_threading/test_functions.py 변경사항
멀티스레딩 환경에서의 회귀를 방지하기 위한 새로운 테스트 케이스들이 추가되었습니다. 특히 test_read_while_replacing_name_attrs, test_repr_while_replacing_qualname, test_c_api_read_while_replacing_module, test_c_api_read_while_replacing_name 등은 다른 스레드에서 함수 이름 관련 속성(__name__, __qualname__, __doc__, __module__)을 변경하는 동안에도 다른 스레드에서 해당 속성을 읽거나 함수를 호출하는 시나리오를 테스트합니다. 이는 이전 버전에서 발생했을 수 있는 데이터 경쟁(data race)이나 사용 후 해제(use-after-free) 문제를 탐지하는 데 중요한 역할을 합니다.
예를 들어, test_read_while_replacing_name_attrs는 다음과 같은 시나리오를 테스트합니다:
def target():
yield
def writer():
for _ in range(ITERS):
target.__name__ = random_string()
target.__qualname__ = random_string()
target.__doc__ = random_string()
target.__module__ = random_string()
def reader():
for _ in range(ITERS):
target.__name__
target.__qualname__
target.__doc__
target.__module__
# ... (생략) ...
threading_helper.run_concurrently(
[writer, reader] * (NUM_THREADS // 2))
이 테스트는 writer 스레드가 함수 속성을 계속 변경하는 동안 reader 스레드가 해당 속성을 안전하게 읽을 수 있는지 검증합니다.
4. Objects/funcobject.c (내부 변경사항, diff에 일부 포함)
리뷰어 mpage의 지적에 따라, PyFunction_SetAnnotations 함수 내부에서 발생하는 잠금 및 객체 해제 로직이 개선되었습니다. 이전에는 PyFunction_SetAnnotations 호출 시 STW가 발생하여 안전했지만, 이제는 각 객체별 잠금(Py_BEGIN_CRITICAL_SECTION)을 사용하고, 이전 값을 안전하게 해제하기 위해 Py_XDECREF를 명시적으로 호출합니다. 이는 다른 스레드에서 콜백 함수 등이 실행되어 잠금을 해제하고 다른 작업을 수행하는 동안에도 이전 객체가 안전하게 관리되도록 보장합니다.
리뷰어 제안 코드:
Py_BEGIN_CRITICAL_SECTION(func);
PyObject *old_annotations = func->func_annotations;
PyObject *old_annotate = func->func_annotate;
func->func_annotations = annotations;
func->func_annotate = NULL;
Py_END_CRITICAL_SECTION();
Py_XDECREF(old_annotations);
Py_XDECREF(old_annotate);
또한, PyFunction_GetAnnotations와 같은 읽기 함수들도 STW 없이 안전하게 동작하도록 수정되었습니다. 이는 이전에는 STW 때문에 암묵적으로 보장되었던 동시성 문제를 명시적으로 해결한 것입니다.
5. 문서 변경사항 (Doc/c-api/function.rst, Doc/c-api/reflection.rst, Misc/NEWS.d/next/...)
C API 문서와 NEWS 파일이 업데이트되어, free-threaded 빌드에서 함수 객체의 가변 필드에 직접 접근할 때의 동기화 요구사항이 명시되었습니다. 특히, 다른 스레드에서 변경될 가능성이 있는 경우 Py_XNewRef를 사용하여 참조를 복사해야 함을 강조합니다. 이는 개발자들이 C API를 사용할 때 발생할 수 있는 잠재적인 동시성 문제를 인지하고 방지하도록 돕습니다.
왜 이게 좋은가?
이 PR의 가장 큰 장점은 멀티스레딩 환경에서의 성능 향상입니다. 제공된 벤치마크 결과는 이 개선의 효과를 명확하게 보여줍니다.
벤치마크 결과 (release free-threaded build, 5000 iterations):
| workload | build | 1t | 2t | 4t | 8t |
|---|---|---|---|---|---|
set __name__ |
main | 0.002 | 4.95 | 4.86 | 5.99 |
set __name__ |
this PR | 0.001 | 0.001 | 0.001 | 0.002 |
set __name__ + __doc__ |
main | 0.004 | 7.70 | 10.02 | 11.47 |
set __name__ + __doc__ |
this PR | 0.002 | 0.002 | 0.001 | 0.002 |
| functools.wraps | main | 0.014 | 18.53 | 22.58 | 28.79 |
| functools.wraps | this PR | 0.010 | 0.010 | 0.010 | 0.014 |
결과를 보면, 특히 여러 스레드(2t, 4t, 8t)에서 __name__ 속성을 설정하거나 functools.wraps를 사용할 때, 기존 빌드에서는 성능이 수백 배까지 저하되었지만, 이 PR이 적용된 빌드에서는 거의 성능 저하가 발생하지 않고 일정한 성능을 유지합니다. 이는 STW 현상이 제거되었기 때문입니다. STW는 모든 스레드를 멈추기 때문에, 스레드 수가 증가할수록 그 영향이 기하급수적으로 커집니다. 이 PR은 각 객체별 잠금과 원자적 연산을 통해 이러한 전역적인 병목 현상을 제거했습니다.
일반적인 교훈:
- GIL과 동시성: Python의 GIL은 단일 스레드 성능을 보장하지만, 멀티스레딩 환경에서는 동시성 문제를 야기할 수 있습니다. 특히 GIL이 해제되는 I/O 바운드 작업이나, 이번 PR처럼 C 확장 등에서 GIL을 관리하는 방식에 따라 성능이 크게 달라질 수 있습니다. free-threaded 빌드는 GIL을 해제하는 시점을 더 세밀하게 제어하여 CPU 바운드 작업에서도 병렬성을 높이려 하지만, 이 과정에서 발생하는 동시성 문제를 해결하는 것이 중요합니다.
- 세밀한 잠금(Fine-grained Locking): 전역 잠금(Global Lock)은 구현이 간단하지만 성능 병목의 주범이 됩니다. 가능한 경우, 객체별 잠금(Per-object Lock)이나 더 세밀한 잠금 메커니즘을 사용하여 경합(contention)을 줄여야 합니다. 이 PR은 함수 객체의 속성 변경 시 전역 잠금 대신 객체별 잠금을 사용함으로써 이를 성공적으로 보여줍니다.
- 원자적 연산의 중요성: 여러 스레드가 공유하는 데이터를 읽거나 쓸 때, 데이터의 일관성을 보장하기 위해 원자적 연산(atomic operations)이 필수적입니다.
_Py_atomic_load_ptr_acquire와 같은 원자적 로드 연산은 데이터 경쟁 없이 안전하게 값을 읽을 수 있도록 합니다. - 테스트의 중요성: 멀티스레딩 관련 버그는 재현하기 어렵고 디버깅하기 까다롭습니다. 이 PR에 추가된 다양한
test_free_threading테스트 케이스들은 이러한 복잡한 동시성 문제를 해결하고, 향후 회귀를 방지하는 데 결정적인 역할을 합니다. 특히, 실제 사용 시나리오를 모방한 테스트는 버그를 조기에 발견하는 데 매우 효과적입니다. - 리뷰어의 역할: 리뷰어들의 날카로운 지적과 제안은 코드의 완성도를 높이는 데 크게 기여했습니다.
mpage와nascheme의 피드백은 잠금 전략, 원자적 연산 사용법, 문서화 등 코드의 안정성과 효율성을 더욱 강화하는 데 도움을 주었습니다.
결론
이번 PR은 Python의 함수 객체 속성 변경 시 발생하는 Stop-the-World 문제를 해결함으로써, 특히 멀티스레딩 환경에서 functools.wraps와 같은 기능을 사용할 때의 성능을 획기적으로 개선했습니다. 객체별 잠금과 원자적 연산을 도입하고, 철저한 테스트 케이스를 추가함으로써 코드의 안정성과 효율성을 모두 높였습니다. 이는 CPython의 지속적인 성능 최적화 노력의 좋은 예시이며, 앞으로도 이러한 개선들이 Python의 성능 향상에 크게 기여할 것으로 기대됩니다.
참고 자료
- https://docs.python.org/3/library/functools.html#functools.wraps
- https://docs.python.org/3/c-api/function.html#c.PyFunction_GET_MODULE
- https://docs.python.org/3/c-api/function.html#c.PyFunction_GET_ANNOTATIONS
- https://docs.python.org/3/c-api/refcounting.html#c.Py_XNewRef
- https://docs.python.org/3/c-api/threads.html#freethreading-extensions-howto
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
PR Analysis 의 다른글
- 이전글 [flashinfer] FlashInfer의 새로운 실험적 기능: NVFP4 기반 MLA 디코드 최적화
- 현재글 : [cpython] Python CPython: 함수 이름 속성 설정 시 Stop-the-World 방지로 멀티스레딩 성능 대폭 개선
- 다음글 [flashinfer] FlashInfer의 MXFP4 SiTU MoE 성능 최적화: Cluster Split-K와 CuTe DSL 개선
댓글