[cpython] CPython unicodedata.normalize() 최적화: Py_UCS4 버퍼 직접 조작으로 성능 향상
PR 링크: python/cpython#150782 상태: Merged | 변경: +23 / -39
들어가며
Python의 unicodedata 모듈은 유니코드 문자열의 정규화(Normalization)를 담당합니다. 유니코드 정규화는 다양한 방식으로 표현될 수 있는 동일한 의미의 문자열을 표준화된 형태로 변환하는 과정으로, 특히 결합 문자(combining characters)의 순서를 정렬하는 것이 중요한 부분입니다. 예를 들어, 악센트가 붙은 문자는 기본 문자와 악센트 문자의 조합으로 표현될 수 있으며, 이들의 순서는 정규화 형태에 따라 달라질 수 있습니다.
이 PR(Pull Request)은 unicodedata.normalize() 함수 내부에서 발생하는 정렬(sorting) 과정의 비효율성을 개선하는 것을 목표로 합니다. 기존 구현은 정렬 대상이 되는 유니코드 문자열을 PyUnicodeObject 형태로 미리 생성한 후, 이 객체의 내부 데이터를 PyUnicode_READ() 및 PyUnicode_WRITE() 매크로를 통해 접근하여 정렬했습니다. 하지만 이 매크로들은 PyUnicodeObject의 내부 인코딩(1바이트, 2바이트, 4바이트)에 따라 분기 처리를 해야 하므로, 불필요한 오버헤드를 발생시켰습니다. 이 PR은 Py_UCS4 타입의 원시 버퍼(raw buffer)를 직접 조작하도록 변경하여 이러한 오버헤드를 제거하고 성능을 향상시킵니다.
코드 분석: Modules/unicodedata.c
핵심 변경 사항은 unicodedata.c 파일 내의 정렬 함수들(canonical_ordering_sort_insertion, canonical_ordering_sort_counting)과 이들을 호출하는 nfd_nfkd 함수에 있습니다.
1. canonical_ordering_sort_insertion 함수 변경
이 함수는 짧은 길이의 결합 문자 시퀀스를 정렬할 때 사용되는 삽입 정렬(insertion sort) 구현입니다. 기존에는 PyUnicodeObject의 kind와 data 포인터를 받아 PyUnicode_READ()/PyUnicode_WRITE()를 사용했습니다.
Before:
static void
canonical_ordering_sort_insertion(int kind, void *data,
Py_ssize_t start, Py_ssize_t end)
{
for (Py_ssize_t i = start + 1; i < end; i++) {
Py_UCS4 code = PyUnicode_READ(kind, data, i);
unsigned char combining = _getrecord_ex(code)->combining;
Py_ssize_t j = i;
while (j > start) {
Py_UCS4 previous = PyUnicode_READ(kind, data, j - 1);
if (_getrecord_ex(previous)->combining <= combining) {
break;
}
PyUnicode_WRITE(kind, data, j, previous);
j--;
}
if (j != i) {
PyUnicode_WRITE(kind, data, j, code);
}
}
}
After:
static void
canonical_ordering_sort_insertion(Py_UCS4 *data, Py_ssize_t length)
{
for (Py_ssize_t i = 1; i < length; i++) {
Py_UCS4 code = data[i];
unsigned char combining = _getrecord_ex(code)->combining;
Py_ssize_t j = i;
while (j > 0) {
Py_UCS4 previous = data[j - 1];
if (_getrecord_ex(previous)->combining <= combining) {
break;
}
data[j] = previous;
j--;
}
if (j != i) {
data[j] = code;
}
}
}
개선점:
int kind, void *data, Py_ssize_t start, Py_ssize_t end 매개변수가 Py_UCS4 *data, Py_ssize_t length로 변경되었습니다. 이는 함수가 PyUnicodeObject의 추상화된 데이터가 아닌, Py_UCS4 타입의 원시 배열 포인터를 직접 받아서 처리함을 의미합니다. 결과적으로 PyUnicode_READ()와 PyUnicode_WRITE() 호출이 data[i]와 같은 직접적인 배열 접근으로 대체되어, 매크로 내부의 kind 기반 분기 처리 오버헤드가 사라집니다.
2. canonical_ordering_sort_counting 함수 변경
이 함수는 비교적 긴 길이의 결합 문자 시퀀스를 정렬할 때 사용되는 계수 정렬(counting sort) 구현입니다. 이 역시 PyUnicode_READ()/PyUnicode_WRITE() 사용을 제거했습니다.
Before: (diff에 직접적인 Before 코드는 없지만, canonical_ordering_sort_insertion과 유사하게 PyUnicode_READ/WRITE를 사용했을 것으로 추정)
// ... (PyUnicode_READ/WRITE 사용)
for (Py_ssize_t i = 0; i < run_length; i++) {
PyUnicode_WRITE(kind, data, start + i, sortbuf[i]);
}
After:
static void
canonical_ordering_sort_counting(Py_UCS4 *data, Py_ssize_t length,
Py_UCS4 *sortbuf)
{
Py_ssize_t counts[256] = {0};
Py_ssize_t total = 0;
for (Py_ssize_t i = 0; i < length; i++) {
Py_UCS4 code = data[i];
unsigned char combining = _getrecord_ex(code)->combining;
counts[combining]++;
}
for (int i = 1; i < 256; i++) {
total += counts[i];
counts[i] = total;
}
/* Reuse counts[] as the next output slot for each CCC. */
for (Py_ssize_t i = 0; i < length; i++) {
Py_UCS4 code = data[i];
unsigned char combining = _getrecord_ex(code)->combining;
sortbuf[counts[combining]++] = code;
}
memcpy(data, sortbuf, length * sizeof(Py_UCS4));
}
개선점:
마찬가지로 Py_UCS4 *data, Py_ssize_t length 매개변수를 사용하여 직접 배열 접근을 수행합니다. 특히, 정렬된 결과를 sortbuf에서 data로 복사하는 마지막 단계에서 PyUnicode_WRITE() 루프 대신 memcpy() 함수를 사용하도록 변경되었습니다. memcpy()는 메모리 블록을 고속으로 복사하는 데 최적화된 C 표준 라이브러리 함수이므로, 개별 문자 쓰기보다 훨씬 효율적입니다.
3. nfd_nfkd 함수 변경
nfd_nfkd 함수는 unicodedata.normalize()의 실제 구현체로, 내부적으로 Py_UCS4 버퍼(output)를 구성하고, 이 버퍼에 대해 정렬 함수들을 호출합니다. 이 PR의 핵심은 PyUnicodeObject의 생성 시점을 정렬 작업이 모두 완료된 후로 미루는 것입니다.
Before:
// ...
result = PyUnicode_FromKindAndData(PyUnicode_4BYTE_KIND,
output, o);
PyMem_Free(output);
if (!result)
return NULL;
result_kind = PyUnicode_KIND(result);
result_data = PyUnicode_DATA(result);
/* Sort each consecutive combining-character run canonically. */
i = 0;
while (i < o) {
// ...
if (run_length < CANONICAL_ORDERING_COUNTING_SORT_THRESHOLD) {
canonical_ordering_sort_insertion(result_kind, result_data,
run_start, i);
continue;
}
// ...
canonical_ordering_sort_counting(result_kind, result_data,
run_start, i, sortbuf);
}
PyMem_Free(sortbuf);
return result;
}
After:
// ...
/* Sort each consecutive combining-character run canonically. */
i = 0;
while (i < o) {
// ...
if (run_length < CANONICAL_ORDERING_COUNTING_SORT_THRESHOLD) {
canonical_ordering_sort_insertion(output + run_start, run_length);
continue;
}
if (run_length > sortbuflen) {
Py_UCS4 *new_sortbuf = PyMem_Resize(sortbuf, Py_UCS4, run_length);
if (new_sortbuf == NULL) {
PyErr_NoMemory();
PyMem_Free(sortbuf);
PyMem_Free(output); // 변경: result 대신 output 해제
return NULL;
}
sortbuf = new_sortbuf;
sortbuflen = run_length;
}
canonical_ordering_sort_counting(output + run_start, run_length,
sortbuf);
}
PyMem_Free(sortbuf);
result = PyUnicode_FromKindAndData(PyUnicode_4BYTE_KIND, output, o); // 변경: 이 줄이 뒤로 이동
PyMem_Free(output);
return result;
}
개선점:
기존에는 output 버퍼를 PyUnicodeObject(result)로 변환한 후, result_kind와 result_data를 통해 정렬 함수에 전달했습니다. 이 PR에서는 PyUnicode_FromKindAndData() 호출을 모든 정렬 작업이 완료된 후로 옮겼습니다. 이로써 정렬 함수들은 output 버퍼의 특정 구간(output + run_start)을 직접 조작하게 됩니다. 이는 PyUnicodeObject의 생성 및 내부 데이터 접근을 위한 추가적인 오버헤드를 완전히 제거합니다. 또한, PyMem_Resize 실패 시 result 대신 output을 해제하도록 에러 처리 로직도 변경되었습니다.
왜 이게 좋은가
이 PR의 최적화는 다음과 같은 이유로 매우 효과적입니다.
-
PyUnicode_READ()/PyUnicode_WRITE()매크로 오버헤드 제거:PyUnicode_READ()와PyUnicode_WRITE()는PyUnicodeObject의 내부 인코딩(kind)에 따라 1바이트, 2바이트, 4바이트 문자 데이터를 다르게 처리하는 매크로입니다. 이는 유연성을 제공하지만, 내부적으로if/else if분기 처리를 포함합니다. 이 PR에서는Py_UCS4(4바이트 유니코드 코드 포인트) 버퍼를 직접 다루므로, 이러한kind기반 분기 처리 없이data[i]와 같은 직접적인 배열 접근이 가능해져 오버헤드가 크게 줄어듭니다. -
memcpy()를 통한 고속 메모리 복사:canonical_ordering_sort_counting함수에서PyUnicode_WRITE()루프를memcpy()로 대체한 것은 중요한 성능 개선입니다.memcpy()는 대부분의 시스템에서 어셈블리 레벨로 최적화되어 있으며, CPU의 SIMD(Single Instruction, Multiple Data) 명령어 등을 활용하여 대량의 메모리 데이터를 매우 빠르게 복사할 수 있습니다. 이는 개별 요소를 반복적으로 쓰는 것보다 훨씬 효율적입니다. -
객체 생성 지연:
PyUnicodeObject의 생성을 정렬 작업이 모두 끝난 후로 미룸으로써, 정렬 과정에서 불필요한 Python 객체 관련 오버헤드를 피할 수 있습니다. C 레벨에서 원시 버퍼를 다루는 것이 Python 객체를 다루는 것보다 훨씬 빠릅니다.
성능 수치 (Benchmarks by serhiy-storchaka)
리뷰어 serhiy-storchaka가 제공한 벤치마크 결과는 이 최적화의 효과를 명확히 보여줍니다.
-
긴 결합 문자 시퀀스:
s=("a"+"\u0300\u0327"*1000)*100(1000개의 결합 문자가 100번 반복)- Baseline: 3.76 msec per loop
- This PR: 3.57 msec per loop
- 약 5% 성능 향상
-
짧은 결합 문자 시퀀스 다수:
s=("a"+"\u0300\u0327"*9)*10000(9개의 결합 문자가 10000번 반복)- Baseline: 3.99 msec per loop
- This PR: 3.84 msec per loop
- 약 3.7% 성능 향상
이러한 수치는 unicodedata.normalize()와 같이 자주 사용될 수 있는 핵심 라이브러리 함수에서 상당한 개선을 의미합니다. 특히 텍스트 처리량이 많은 애플리케이션에서는 누적 효과가 클 수 있습니다.
일반적 교훈
이 최적화는 저수준(low-level) C/C++ 프로그래밍에서 성능을 개선하기 위한 중요한 교훈을 제공합니다:
- 추상화 비용 인지: 편리한 추상화(예:
PyUnicode_READ/WRITE매크로)는 종종 성능 오버헤드를 동반합니다. 성능이 중요한 코드 경로에서는 이러한 추상화의 필요성을 재평가하고, 가능하다면 더 직접적인 메모리 접근 방식을 고려해야 합니다. - 원시 버퍼 활용: 데이터 구조의 내부 표현을 명확히 알고 있다면, 원시 메모리 버퍼(raw memory buffer)를 직접 조작하는 것이 객체 지향적 API를 사용하는 것보다 빠를 수 있습니다.
- 최적화된 라이브러리 함수 사용:
memcpy()와 같이 시스템 수준에서 고도로 최적화된 함수들은 커스텀 루프보다 훨씬 효율적입니다. 대량의 데이터 복사나 조작 시에는 이러한 함수들을 적극적으로 활용해야 합니다. - 객체 생성 지연: 불필요한 객체 생성은 메모리 할당/해제 및 참조 카운트 관리 등 추가적인 비용을 발생시킵니다. 최종 결과가 준비될 때까지 객체 생성을 지연하는 것은 성능 향상에 도움이 될 수 있습니다.
리뷰어 피드백
serhiy-storchaka는 구체적인 벤치마크를 제공하여 이 PR의 성능 개선 효과를 검증했습니다. eendebakpt는 find_nfc_index 함수에 대한 또 다른 최적화 아이디어를 제시했지만, serhiy-storchaka는 이를 이 PR과 별개의 문제로 보고 새로운 이슈(gh-150889)로 분리하도록 제안했습니다. 이는 PR 리뷰 과정에서 단일 책임 원칙(Single Responsibility Principle)을 지키며, 각 변경 사항의 범위를 명확히 하는 좋은 예시입니다.
결론
이 PR은 unicodedata.normalize() 함수의 내부 정렬 로직을 Py_UCS4 버퍼 직접 조작과 memcpy() 활용을 통해 최적화하여, CPython의 핵심 기능 중 하나인 유니코드 정규화의 성능을 실질적으로 향상시켰습니다. 이는 저수준 최적화가 전체 시스템 성능에 미치는 긍정적인 영향을 잘 보여주는 사례입니다.
참고 자료
- https://docs.python.org/3/library/unicodedata.html#unicodedata.normalize
- https://docs.python.org/3/c-api/unicode.html
- https://en.cppreference.com/w/c/string/byte/memcpy
- https://docs.python.org/3/c-api/memory.html#c.PyMem_Resize
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
- [cpython] Python re 모듈의 findall, sub, subn 성능 개선: PyList_AppendTakeRef 도입
- [cpython] CPython 내부 들여다보기: logging.getLogger()는 어떻게 33% 더 빨라졌나?
- [cpython] tarfile 스트리밍 모드(r|*) 성능 개선: 파이썬 압축 파일 처리의 숨겨진 병목 제거
- [cpython] Python의 os.fork 후 발생하던 성능 프로파일링 충돌 문제 해결 및 최적화 분석
- [cpython] CPython의 PySequence_GetSlice 성능 개선: 불필요한 참조 카운트 연산 제거
PR Analysis 의 다른글
- 이전글 [sglang] SGLang: DeepSeek-R1 FP8 GEMM 성능 회귀 문제 해결 및 최적화
- 현재글 : [cpython] CPython unicodedata.normalize() 최적화: Py_UCS4 버퍼 직접 조작으로 성능 향상
- 다음글 [sglang] AMD GPU 최적화: Triton 커널 퓨전을 통한 Qwen2 MoE 공유 전문가 게이팅 성능 향상
댓글