![]()
`return`은 공짜가 아니다: CPython이 RETURN_VALUE를 쪼갠 이유(_MAKE_HEAP_SAFE)
성능 이슈를 추적하다 보면, 마지막에 이상한 곳으로 떨어질 때가 있다.
쿼리도 최적화했고, 캐시도 붙였고, 알고리즘도 손봤는데, 그래도 처리량이 더 안 올라간다. 프로파일러를 켜면 프레임워크 내부가 보이고, 그 안에서 또 제너레이터가 보이고, 그 안에서 또 yield와 return이 보인다.
이때 팀원 한 명이 이렇게 말한다.
“마지막 한 줄이 병목일 수도 있겠네요.”
return과 yield는 파이썬에서 너무 평범해서, 병목 후보로 올려놓기조차 어렵다. 하지만 CPython은 최근 이 “평범한 끝”을 손봤다. RETURN_VALUE/YIELD_VALUE가 _MAKE_HEAP_SAFE uop를 앞에 두는 매크로 확장으로 바뀌었다(참고자료).
이 글의 목적은 “return을 줄여라”가 아니다. 오히려 반대다. 우리가 성능을 볼 때 ‘내 코드’만 보지 말고, 인터프리터가 어떤 모양으로 실행되는지도 함께 보라는 얘기다. 특히 tier2/uop 같은 실행 경로가 생긴 이후에는, 이런 변화가 실무에서 체감될 수 있다.
yield/return이 많은 코드가 실제로 생기는 곳
실무에서 yield가 많은 코드는 흔하다.
- 스트리밍 응답을 만들 때
- 배치 작업에서 레코드를 흘려보낼 때
- 이벤트를 단계별로 처리할 때
그리고 이런 코드는 보통 “프레임워크가 하라는 대로” 쓰다 보면 나온다. 내가 원해서 yield를 남발한 게 아니라, 인터페이스가 그렇게 생겼다.
예를 들어 아래처럼 단계별로 값을 내보내는 제너레이터는 자연스럽다.
def parse_lines(lines):
for line in lines:
if not line:
continue
yield line.strip()
def pipeline(lines):
for x in parse_lines(lines):
if x.startswith("#"):
continue
yield x
return 0
이 코드는 느리다고 비난받을 종류가 아니다. 그런데 이런 코드가 “엄청 많이” 호출되면, 마지막 한 줄도 비용이 된다. 특히 로그/변환/필터처럼 얇은 단계가 여러 겹이면 더 빨리 누적된다.
그래서 CPython이 RETURN_VALUE/YIELD_VALUE 같은 ‘끝’에 손을 대는 건 의미가 있다. 병목이 언제나 알고리즘에만 있는 게 아니라는 뜻이니까.
heap-safe/borrowed/refcount가 왜 여기서 튀어나오나
PR 제목만 보면 갑자기 낯선 단어가 나온다.
- heap-safe
- refcount operations
- uop
이걸 다 외우라는 말은 아니다. 실무에서 중요한 건 “왜 RETURN_VALUE/YIELD_VALUE에 이런 단어가 끼어들었나”다.
free-threaded 흐름이든, tier2/uop 최적화 흐름이든, CPython은 실행 경로를 더 잘게 쪼개서 최적화하기 좋은 단위로 만들고 있다.
그 과정에서 “이 값은 지금 안전한가?” 같은 질문이 생긴다. 안전하지 않으면 안전하게 만들어야 한다.
실무에서 이게 체감되는 순간은 의외로 단순하다. 로깅/예외 메시지/상태 전달 때문에 작은 객체가 오가는데, 그 경로가 제너레이터/코루틴으로 잘게 나뉘어 있을 때다. 개별 함수는 짧고 깔끔한데, 전체 호출 횟수가 많아지면서 “마지막 한 줄”이 묵직해진다.
그런데 이 안전 조치가 매번 무조건 들어가면, 불필요한 refcount 조작이 늘어날 수 있다. 그리고 그건 특히 RETURN/YIELD 같은 자주 밟는 경로에서 아프다.
PR의 핵심을 실무 감각으로 번역하면 이런 말이 된다.
RETURN/YIELD 경로에서 필요 없는 ‘안전 조치’를 덜 하게 만들자.
PR이 바꾼 것: RETURN_VALUE를 매크로 확장으로 만들기
이번 변경을 “매크로를 더 썼다”로만 보면 놓친다. 중요한 건 실행 경로가 “두 단계”로 보이게 되었다는 점이다.
이건 실제 CPython 코드를 그대로 옮긴 게 아니라, 변화의 모양만 잡아보는 의사코드다.
# before: RETURN_VALUE/YIELD_VALUE 안에서 ‘힙 안전화’ 성격의 작업이 한 덩어리로 숨어 있음
RETURN_VALUE(value):
value = maybe_make_heap_safe(value)
do_return(value)
# after: 매크로 확장으로 쪼개져서, 최적화가 “필요한 경우만” _MAKE_HEAP_SAFE를 남길 여지가 생김
RETURN_VALUE:
_MAKE_HEAP_SAFE
_RETURN_VALUE
YIELD_VALUE:
_MAKE_HEAP_SAFE
_YIELD_VALUE
이렇게 쪼개면 무슨 일이 좋아지냐.
tier2/uop 최적화 관점에서 “항상 필요한 부분”과 “상황에 따라 생략 가능한 부분”이 분리된다. 그러면 불필요한 refcount 조작을 줄일 여지가 생긴다.
중요한 건, 이게 파이썬 개발자에게 “문법을 바꿔라”라고 말하지 않는다는 점이다. 대신 인터프리터가 더 똑똑하게 같은 코드를 돌릴 수 있게 길을 내준다.
실무에서 얻는 힌트: 측정과 업그레이드 판단이 달라진다
이런 패치는 당장 내 서비스에 눈에 보이는 차이를 주지 않을 수도 있다.
하지만 한 번 경험하면 관점이 달라진다. 프로파일링에서 “제너레이터가 많아서 느리다”로 끝내지 않고, “마지막 경로(RETURN/YIELD)가 얼마나 자주 밟히지?” 같은 질문이 살아난다.
이 질문은 업그레이드 판단으로도 이어진다.
내 코드가 바뀌지 않아도, 런타임이 바뀌면 성능이 달라질 수 있다는 감각. 특히 tier2/uop 같은 내부 최적화가 활발한 시기에는 이런 변화가 더 자주 나온다.
측정은 거창할 필요가 없다. 예를 들어 반복 호출이 많은 경로에서 아주 작은 반복을 만들어 감각을 잡을 수 있다.
import time
def gen(n):
for i in range(n):
yield i
start = time.perf_counter()
for _ in range(200_000):
for _ in gen(3):
pass
end = time.perf_counter()
print(end - start)
이걸로 PR의 효과를 직접 입증하자는 얘기는 아니다. 다만 yield/return 같은 “끝 경로”가 호출 횟수가 쌓이면 비용이 된다는 감각을 떠올리게 해준다.
특히 이런 코드에서 체감이 난다. 스트리밍 파이프라인처럼 작은 제너레이터를 여러 겹으로 감싸서 데이터를 흘리거나, 이벤트 루프 주변에서 짧은 코루틴/제너레이터가 촘촘히 호출되는 경우. 애플리케이션 로직은 얇은데, yield/return이 너무 자주 등장해서 ‘마지막 한 줄’이 실제 비용이 된다.
그래서 이런 PR은 “내 코드가 잘못됐다”가 아니라, 인터프리터가 자주 밟히는 계단을 조금씩 낮추는 작업에 가깝다.
마무리: 인터프리터 최적화는 개발자에게 ‘질문’을 바꿔준다
RETURN_VALUE/YIELD_VALUE에 _MAKE_HEAP_SAFE를 명시적으로 앞에 두는 매크로 확장으로 바뀐 건, 단지 내부 리팩터링이 아니다.
인터프리터가 최적화하기 좋은 모양을 얻기 위해, 실행 경로를 더 잘게 쪼개고, 불필요한 refcount 조작을 줄이려는 흐름의 일부다(참고자료).
실무 개발자가 얻는 건 “파이썬이 느리다/빠르다” 같은 일반론이 아니다. 병목이 내 코드가 아니라 런타임 경로일 수도 있고, 그 경로는 릴리즈 하나로 달라질 수 있다.
그래서 성능 디버깅의 마지막에서 “내 코드는 다 봤는데…”가 나오면, 다음 질문을 한 번만 더 던져보면 된다. yield/return 같은 끝 경로가 얼마나 자주 밟히고 있는지. 이번 PR은 그 끝 경로를 인터프리터가 더 최적화하기 쉬운 모양으로 정리한 사례다.
참고자료
- CPython PR: gh-144540 — Add _MAKE_HEAP_SAFE uop to eliminate unnecessary refcount operations in RETURN_VALUE and YIELD_VALUE
- https://github.com/python/cpython/pull/144414
- PR diff
- https://github.com/python/cpython/pull/144414.diff
- Merge commit
- https://github.com/python/cpython/commit/f062014d3876f1f81c0e60bf861c3460429ac3b4
댓글
댓글 쓰기