본문으로 건너뛰기

[CPython] JIT float 연산 최적화 — 유일 참조 피연산자 재사용

PR 링크: python/cpython#146307 상태: Merged | 변경: +2349 / -1231

들어가며

CPython의 float 이진 연산(+, -, *)은 매번 새로운 PyFloatObject를 heap에 할당한다. 수치 계산이 많은 루프에서는 이 할당-해제 비용이 상당하다. 이 PR은 Tier 2 JIT optimizer에서 피연산자의 참조 카운트가 1(유일 참조)일 때, 새 객체를 만드는 대신 기존 객체의 ob_fval을 직접 변경하는 최적화를 추가한다.

핵심 코드 분석

새로운 micro-op 정의

Before (기존 _BINARY_OP_ADD_FLOAT):

// 항상 새 PyFloatObject 할당
PyObject *result = PyFloat_FromDouble(a + b);

After (_BINARY_OP_ADD_FLOAT_INPLACE):

tier2 op(_BINARY_OP_ADD_FLOAT_INPLACE, (left, right -- res, l, r)) {
    PyObject *left_o = PyStackRef_AsPyObjectBorrow(left);
    PyObject *right_o = PyStackRef_AsPyObjectBorrow(right);
    assert(PyFloat_CheckExact(left_o));
    assert(_PyObject_IsUniquelyReferenced(left_o));
    double dres = ((PyFloatObject *)left_o)->ob_fval
                + ((PyFloatObject *)right_o)->ob_fval;
    ((PyFloatObject *)left_o)->ob_fval = dres;
    res = left;
    l = PyStackRef_NULL;  // borrowed로 표시 -> POP_TOP_NOP
    r = right;
    INPUTS_DEAD();
}

_PyObject_IsUniquelyReferenced로 왼쪽 피연산자가 유일 참조인지 확인한 후, ob_fval을 직접 변경한다. 새 객체 할당이 완전히 제거된다. 변경된 객체는 l = PyStackRef_NULL로 borrowed 상태가 되어 이후 _POP_TOP_FLOAT_POP_TOP_NOP로 치환된다.

RIGHT 변형

tier2 op(_BINARY_OP_ADD_FLOAT_INPLACE_RIGHT, (left, right -- res, l, r)) {
    FLOAT_INPLACE_OP(left, right, right, +);
    res = right;
    l = left;
    r = PyStackRef_NULL;
    INPUTS_DEAD();
}

덧셈과 곱셈은 교환법칙이 성립하므로, 오른쪽 피연산자가 유일 참조일 때 사용하는 _RIGHT 변형도 제공한다. 뺄셈은 a - b에서 b만 유일 참조일 때 b.ob_fval = a - b로 처리하는 별도 변형이 있다.

Unary 연산

tier2 op(_UNARY_NEGATIVE_FLOAT_INPLACE, (value -- res, v)) {
    PyObject *val_o = PyStackRef_AsPyObjectBorrow(value);
    assert(_PyObject_IsUniquelyReferenced(val_o));
    double dres = -((PyFloatObject *)val_o)->ob_fval;
    ((PyFloatObject *)val_o)->ob_fval = dres;
    res = value;
    v = PyStackRef_NULL;
    INPUTS_DEAD();
}

단항 부정(-x)에도 같은 최적화가 적용된다.

왜 이게 좋은가

마이크로벤치마크 결과:

표현식 기존 (ns/iter) 최적화 (ns/iter) 속도 향상
total += a*b + c 24.0 11.5 2.1x
total += a + b 16.9 11.0 1.5x
total += a*b + c*d 28.5 18.3 1.6x

실제 벤치마크인 pyperformance nbody에서 19% 성능 향상(60.6ms -> 49.0ms)을 달성했다.

핵심 원리는 간단하다. a * b의 결과로 생성된 float 객체는 그 줄의 연산이 끝나면 버려진다. 참조 카운트가 1이므로 안전하게 재사용할 수 있고, heap 할당/해제 한 쌍을 제거한다. 체이닝 연산(a*b + c)에서는 중간 결과가 연쇄적으로 재사용되어 효과가 배가된다.

정리

  • _BINARY_OP_{ADD,SUBTRACT,MULTIPLY}_FLOAT_INPLACE_RIGHT 변형 6개의 micro-op이 추가되었다
  • _UNARY_NEGATIVE_FLOAT_INPLACE 1개가 추가되었다
  • Tier 2 optimizer가 PyJitRef_IsUnique 검사 후 자동으로 이 micro-op을 선택한다
  • JIT only 최적화로, interpreter 모드에는 영향이 없다
  • 향후 나눗셈, int 연산, complex 연산으로 확장 가능하다

참고 자료

⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글