[cpython] CPython `PyFloat_Pack/Unpack2` 최적화: 네이티브 `_Float16` 활용으로 성능 향상
PR 링크: python/cpython#154796 상태: Merged | 변경: +129 / -0
들어가며
Python은 다양한 데이터 타입을 지원하며, 그 중 부동 소수점(floating-point) 연산은 과학 계산, 머신러닝, 그래픽스 등 여러 분야에서 핵심적인 역할을 합니다. CPython 내부에서는 double (64비트)과 같은 표준 부동 소수점 타입을 2바이트 half-precision float (_Float16)으로 변환하거나 그 반대로 변환하는 PyFloat_Pack2 및 PyFloat_Unpack2와 같은 C API 함수들이 사용됩니다. 이 함수들은 주로 struct 모듈의 e 포맷 코드와 같은 저수준 연산에서 활용됩니다.
기존에는 이러한 변환이 복잡한 비트 조작을 통해 소프트웨어적으로 처리되었기 때문에 성능 병목이 발생할 수 있었습니다. 이번 GitHub PR (gh-153740)은 플랫폼에서 네이티브 _Float16 타입이 지원될 경우, 이를 활용하여 PyFloat_Pack2와 PyFloat_Unpack2 함수의 성능을 크게 개선하는 것을 목표로 합니다. 이는 특히 half-precision float 연산이 빈번한 고성능 컴퓨팅 환경에서 Python의 효율성을 높이는 중요한 최적화입니다.
핵심 변경사항 분석
이번 PR의 핵심은 _Float16이라는 C 언어의 네이티브 half-precision float 타입을 활용하여 double과의 변환을 하드웨어 가속으로 처리하는 것입니다. 이를 위해 빌드 시스템 변경과 실제 C API 함수의 구현 변경이 이루어졌습니다.
Objects/floatobject.c 변경: 네이티브 _Float16 활용
PyFloat_Pack2와 PyFloat_Unpack2 함수는 double과 2바이트 half-precision float 간의 변환을 담당합니다. 이 PR은 HAVE_FLOAT16 매크로가 정의된 경우, 즉 컴파일러가 네이티브 _Float16 타입을 지원할 때 최적화된 경로를 사용하도록 변경했습니다.
PyFloat_Pack2 (double -> _Float16)
Before:
int
PyFloat_Pack2(double x, char *data, int le)
{
unsigned char *p = (unsigned char *)data;
unsigned char sign;
int e;
double f;
// ... 복잡한 비트 조작을 통한 변환 로직 ...
}
After:
--- a/Objects/floatobject.c
+++ b/Objects/floatobject.c
@@ -1895,6 +1895,31 @@ int
PyFloat_Pack2(double x, char *data, int le)
{
unsigned char *p = (unsigned char *)data;
+#if HAVE_FLOAT16
+ /* Conversion can change NaNs type or alter payload. Here we
+ just fallback to the generic code, instead of providing
+ workarounds as for single/double precision. */
+ if (!isnan(x)) {
+ _Float16 y = (_Float16)x;
+
+ if (isinf(y) && !isinf(x)) {
+ goto Overflow;
+ }
+
+ unsigned char s[sizeof(_Float16)];
+
+ memcpy(s, &y, sizeof(_Float16));
+ if ((_PY_FLOAT_LITTLE_ENDIAN && !le) || (_PY_FLOAT_BIG_ENDIAN && le)) {
+ p[1] = s[0];
+ p[0] = s[1];
+ }
+ else {
+ p[0] = s[0];
+ p[1] = s[1];
+ }
+ return 0;
+ }
+#endif
unsigned char sign;
int e;
double f;
변경 후 코드에서는 HAVE_FLOAT16이 정의되고 입력 x가 NaN이 아닐 경우, double 값을 직접 _Float16 타입으로 캐스팅(_Float16 y = (_Float16)x;)합니다. 이 캐스팅은 컴파일러가 해당 플랫폼의 하드웨어 명령어를 사용하여 최적화된 변환을 수행하도록 합니다. 변환된 _Float16 값의 바이트를 memcpy를 통해 출력 버퍼 data에 복사하며, 시스템의 엔디안(endianness)에 따라 바이트 순서를 적절히 처리합니다. double이 유한한 값인데 _Float16으로 변환 시 무한대가 되는 오버플로우 상황도 명시적으로 goto Overflow를 통해 처리합니다.
NaN(Not a Number) 값의 경우, _Float16의 NaN 표현 방식이 double과 다를 수 있고 페이로드(payload) 정보가 손실될 수 있기 때문에, 네이티브 변환을 사용하지 않고 기존의 일반적인(generic) 코드 경로로 폴백(fallback)하도록 설계되었습니다. 이는 정확성을 보장하기 위한 신중한 접근 방식입니다.
PyFloat_Unpack2 (_Float16 -> double)
Before:
double
PyFloat_Unpack2(const char *data, int le)
{
unsigned char *p = (unsigned char *)data;
unsigned char sign;
int e;
unsigned int f;
// ... 복잡한 비트 조작을 통한 변환 로직 ...
}
After:
--- a/Objects/floatobject.c
+++ b/Objects/floatobject.c
@@ -2090,6 +2115,24 @@ double
PyFloat_Unpack2(const char *data, int le)
{
unsigned char *p = (unsigned char *)data;
+#if HAVE_FLOAT16
+ _Float16 x16;
+
+ if ((_PY_FLOAT_LITTLE_ENDIAN && !le) || (_PY_FLOAT_BIG_ENDIAN && le)) {
+ char buf[2];
+
+ buf[1] = p[0];
+ buf[0] = p[1];
+ memcpy(&x16, buf, 2);
+ }
+ else {
+ memcpy(&x16, p, 2);
+ }
+ if (!isnan(x16)) {
+ return x16;
+ }
+ /* Fallback to the generic code for NaNs, see PyFloat_Pack2(). */
+#endif
unsigned char sign;
int e;
unsigned int f;
PyFloat_Unpack2 또한 HAVE_FLOAT16이 정의된 경우, 입력 data의 2바이트를 _Float16 x16 변수로 복사합니다. 이 과정에서도 엔디안을 고려한 바이트 스왑이 이루어질 수 있습니다. x16이 NaN이 아닐 경우, _Float16 값을 double로 암시적 변환하여 즉시 반환합니다. 이 역시 컴파일러와 하드웨어의 최적화된 변환 기능을 활용합니다. PyFloat_Pack2와 마찬가지로 NaN 값은 기존의 일반적인 코드 경로로 폴백합니다.
configure.ac 및 configure 변경: _Float16 지원 감지
이 최적화가 모든 플랫폼에서 적용되는 것은 아니므로, 빌드 시점에 _Float16 타입 지원 여부를 정확히 감지하는 것이 중요합니다. configure.ac 스크립트에 새로운 검사 로직이 추가되었습니다.
Before:
dnl Check for libmpdec >= 2.5.0
PKG_CHECK_MODULES([LIBMPDEC], [libmpdec >= 2.5.0], [have_mpdec=yes], [
WITH_SAVE_ENV([
After:
--- a/configure.ac
+++ b/configure.ac
@@ -4469,6 +4469,25 @@ if test "$ac_cv_ffi_complex_double_supported" = "yes"; then
[Defined if _Complex C type can be used with libffi.])
fi
+# Check for native half-float type (_Float16).
+AC_CACHE_CHECK([for _Float16 support], [ac_cv_float16_supported],
+WITH_SAVE_ENV([
+CFLAGS="$CFLAGS -O0"
+AC_RUN_IFELSE([AC_LANG_SOURCE([[
+int main(void)
+{
+ _Float16 val = 1.0f16;
+ double d = 3.14;
+ val = d;
+ return 0;
+}
+]])], [ac_cv_float16_supported=yes],
+[ac_cv_float16_supported=no],
+[ac_cv_float16_supported=no])]))
+AS_VAR_IF([ac_cv_float16_supported], [yes],
+ [AC_DEFINE([HAVE_FLOAT16], [1],
+ [Defined if _Float16 C type is supported])])
+
dnl Check for libmpdec >= 2.5.0
PKG_CHECK_MODULES([LIBMPDEC], [libmpdec >= 2.5.0], [have_mpdec=yes], [
WITH_SAVE_ENV([
이 변경사항은 AC_RUN_IFELSE 매크로를 사용하여 _Float16 타입이 컴파일뿐만 아니라 런타임에서도 double과의 변환이 가능한지 테스트합니다. 초기 PR에서는 컴파일 타임 체크만 있었으나, 리뷰어 StanFromIreland의 피드백(컴파일은 되지만 링커 에러가 발생하는 경우)에 따라 런타임 테스트로 강화되었습니다. 이 덕분에 _Float16 val = 1.0f16; double d = 3.14; val = d;와 같이 실제 변환 코드를 실행하여, 컴파일러와 링커 모두 _Float16을 완전히 지원하는지 확인합니다. 이 테스트가 성공하면 HAVE_FLOAT16 매크로가 정의되어 최적화된 코드 경로가 활성화됩니다.
Lib/test/test_capi/test_float.py 변경: NaN 테스트 개선
Before:
def test_pack_unpack_roundtrip_for_nans(self):
# ...
self.assertTrue(math.isnan(value))
self.assertEqual(data1, data2)
After:
--- a/Lib/test/test_capi/test_float.py
+++ b/Lib/test/test_capi/test_float.py
@@ -226,6 +226,8 @@ def test_pack_unpack_roundtrip_for_nans(self):
value = unpack(data1, endian)
data2 = pack(size, value, endian)
self.assertTrue(math.isnan(value))
+ self.assertEqual(math.copysign(1.0, value),
+ -1.0 if sign else 1.0)
self.assertEqual(data1, data2)
@unittest.skipUnless(HAVE_IEEE_754, "requires IEEE 754")
NaN 값의 경우, _Float16 변환 시 페이로드가 변경될 수 있지만, 부호(sign)는 유지되어야 합니다. 이 테스트 코드 추가는 math.copysign을 사용하여 NaN 값의 부호가 올바르게 유지되는지 확인하여, 변환의 정확성을 더욱 높입니다.
왜 이것이 좋은 최적화/개선인가?
이번 PR은 CPython의 저수준에서 이루어지는 매우 중요한 최적화입니다. 여러 면에서 좋은 개선이라고 평가할 수 있습니다.
1. 압도적인 성능 향상 (하드웨어 가속)
가장 큰 이점은 성능 향상입니다. double과 _Float16 간의 변환은 기존의 비트 조작 방식으로는 CPU 사이클을 많이 소모했습니다. 하지만 최신 CPU 아키텍처(예: Intel AVX512FP16, ARMv8.2-A FP16)는 half-precision float 연산을 위한 전용 하드웨어 명령어를 제공합니다. 네이티브 _Float16 타입을 사용하면 컴파일러가 이러한 하드웨어 명령어를 직접 생성하여 변환 속도를 비약적으로 높일 수 있습니다.
hpkfft의 Google benchmark 결과에 따르면, Intel Sapphire Rapids 프로세서에서 -mavx512f -mavx512fp16 옵션으로 컴파일했을 때 PyFloat_Pack2 함수의 C 코드 레벨 성능이 5.20 ns에서 2.55 ns로 약 2배 향상되었습니다.
Python pyperf 벤치마크에서는 CPython 런타임 오버헤드 때문에 C 코드 레벨만큼의 극적인 차이는 아니지만, 여전히 유의미한 개선을 보였습니다:
- **`pack(
참고 자료
- https://gcc.gnu.org/onlinedocs/gcc/Floating-Types.html
- https://docs.python.org/3/library/struct.html#format-characters
- https://en.cppreference.com/w/c/numeric/math/isnan
- https://en.cppreference.com/w/c/numeric/math/isinf
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
- [cpython] CPython 성능 최적화: re.split의 리스트 빌드 과정 개선하기
- [cpython] Python 문자열 split/splitlines 성능 개선: _PyList_AppendTakeRef 도입
- [cpython] CPython 최적화: functools.reduce 대신 Union 직접 사용하기
- [cpython] CPython unicodedata.normalize() 최적화: Py_UCS4 버퍼 직접 조작으로 성능 향상
- [cpython] Python re 모듈의 findall, sub, subn 성능 개선: PyList_AppendTakeRef 도입
PR Analysis 의 다른글
- 이전글 [flashinfer] FlashInfer에 cuTile 기반 Fused MoE 백엔드 도입: 성능과 유지보수성의 균형
- 현재글 : [cpython] CPython `PyFloat_Pack/Unpack2` 최적화: 네이티브 `_Float16` 활용으로 성능 향상
- 다음글 [onnxruntime] ONNX Runtime의 CPU int4 가중치 프리패킹 최적화: 병렬 처리 효율성 개선
댓글