Simdutf Can Now Be Used Without libc++ or libc++abi

Mitchell Hashimoto

이제 simdutf를 libc++나 libc++abi 없이 사용할 수 있습니다

이 PR부터 simdutf를 libc++나 libc++abi 없이 사용할 수 있게 되었습니다1.

simdutf는 libghostty-vt에서 마지막으로 남은 libc++ 의존성이었습니다2. 새로운 simdutf 빌드를 사용하도록 Ghostty를 업데이트한 뒤, 의존성에서 libc++와 libc++abi를 완전히 제거할 수 있었습니다.

libc++에 의존하지 않을 때의 이점

libc++에 의존하지 않으면 라이브러리의 이식성이 높아지고(임베디드, WebAssembly, freestanding 환경), 크로스 컴파일이 단순해지며(타깃별 C++ 표준 라이브러리가 필요 없습니다), 바이너리 크기가 줄어들고 정적 링킹도 단순해질 수 있습니다.

simdutf와 같은 범용 저수준 라이브러리는 가능한 한 이식성과 유연성을 갖추어야 합니다. 하위 소비자가 libc++를 쉽게 사용할 수 있다면 물론 좋습니다. 하지만 사용할 수 없다고 해서 simdutf 사용이 막혀서는 안 됩니다. 근본적으로 simdutf는 libc++가 필요하지 않기 때문입니다.

libc++ vs libc++abi

프로그램을 libc++에서 분리해 내는 작업은 두 부분으로 나뉩니다.

첫째, libc++std::vector, std::string 등을 제공하는 C++ 표준 라이브러리입니다. <vector>는 물론 <cstring> 같은 libc의 C++ 대체 헤더를 임포트하더라도 libc++에 의존하게 됩니다.

더 교묘한 부분은 libc++abi입니다. 예외 처리, 가상 함수 테이블, RTTI 등을 포함하는 C++ ABI를 제공합니다. 이러한 기능이 필요한 C++ 기능을 사용한다면, C++ 표준 라이브러리 헤더를 하나도 임포트하지 않더라도 전혀 libc++abi에 의존하게 됩니다.

예를 들어 아래의 최소 C++ 프로그램은 함수 지역 정적 변수를 사용하므로 libc++abi에 의존합니다. 이는 C++ ABI의 일부인 스레드 안전 초기화가 필요하기 때문입니다:

struct Implementation {
    int version;
};

const Implementation& get_impl() {
    static const Implementation impl{1};
    return impl;
}

int main() {
    return get_impl().version;
}

simdutf를 libc++에서 분리하기

먼저 libc++에 대해 이야기해 보겠습니다(ABI가 아닙니다).

simdutf는 최신 C++ 기능을 적극적으로 활용하는 C++ 라이브러리이며, 개인적으로 알지는 못하지만 Daniel Lemire는 C++ 기능을 최대한 활용하는 것을 무척 좋아하는 것 같습니다. 그런 의미에서 simdutf는 명실상부한 C++ 프로젝트입니다.

제 변경 사항이 받아들여질 가능성을 조금이라도 높이려면, 프로젝트가 불편함 없이 계속 C++ 기능을 사용할 수 있도록 해야 했습니다.

STL 사용

제가 택한 접근법은 모든 C++ 표준 라이브러리 타입을 한곳에 모은 stl_compat.h 헤더를 도입하는 것이었습니다. 일반적인 libc++ 모드에서는 stl_compat.h의 모든 내용이 해당 C++ 표준 라이브러리 타입에 대한 단순한 include나 최소한의 별칭에 불과해 런타임 오버헤드가 없습니다.

NO_LIBCXX 모드에서는 stl_compat.h가 simdutf에서 사용하는 C++ 타입들에 대한 자체 구현을 제공하지만, simdutf가 필요로 하는 수준과 호환될 만큼만 최소한으로 제공합니다. 예를 들어 stl_compat.hstd::pair에 대한 자체 구현을 제공합니다.

그 결과 diff 전반에 걸쳐 필요한 변경 사항은 대부분 다음과 같은 형태가 됩니다:

-std::pair<const char *, char32_t *>
+internal::pair<const char *, char32_t *>
arm_convert_latin1_to_utf32(const char *buf, size_t len,
                            char32_t *utf32_output) {

ABI 호환성

제 목표는 가능한 한 ABI 호환성을 최대한 유지하는 것이었습니다. 일부 공개 ABIstd::string 같은 C++ 타입을 노출하므로, 그런 경우에는 ABI를 깨뜨릴 수밖에 없었습니다. 그 외의 경우에는 ABI가 완전히 유지됩니다.

SIMDUTF_NO_LIBCXX는 컴파일 단위에 변화를 주는 새로운 기능이므로, 이 플래그가 있을 때만 ABI가 깨지는 것은 허용 가능하다고 판단했습니다. NO_LIBCXX 플래그가 없는 기존 경우에는 ABI가 완전히 유지되며, 기존 사용자의 ABI를 깨뜨리지 않고도 simdutf를 업데이트할 수 있습니다.

ABI 깨짐이 매우 최소한에 그치고, 진단 함수 몇 개(예: 활성 구현의 이름을 가져오는 함수)나 다른 C++ 타입과 함께 동작하는 헬퍼(예: 텍스트 인코딩용 std::string)에만 해당한다는 사실을 확인하고는 오히려 놀랐습니다. 정의상 SIMDUTF_NO_LIBCXX를 사용하는 사람은 libc++에 관심이 없으므로, 이러한 ABI 깨짐은 버그라기보다 기능처럼 느껴졌습니다.

simdutf를 libc++abi에서 분리하기

이 작업은 훨씬 더 복잡했습니다.

가장 큰 문제는 libc++abi 의존성이 보통 소스 수준의 include로 명확하게 드러나지 않는다는 점이었습니다. 평범해 보이는 언어 기능에 대해 컴파일러가 조용히 C++ ABI 런타임 호출을 생성하기 때문에 생기는 문제입니다. 이를 탐지하기 위해 오브젝트 파일을 디컴파일하고 __cxa_guard_acquire 같은 심볼을 찾는 스크립트를 작성해야 했습니다.

simdutf에서 가장 큰 원인은 런타임 디스패치 레이어였습니다. 원래 코드는 함수 지역 정적 변수에 크게 의존하고 있었습니다. C++에서는 이러한 지역 변수들이 C++ ABI 런타임이 제공하는 __cxa_guard_acquire__cxa_guard_release 같은 스레드 안전 초기화 헬퍼로 보호됩니다. 따라서 코드 어디에서도 libc++abi를 언급하지 않았더라도 컴파일된 오브젝트는 여전히 이에 의존했습니다. 이를 해결하기 위해 NO_LIBCXX 모드에서는 함수 지역 정적 변수 대신 변환 단위 정적 변수를 사용했습니다.

#if SIMDUTF_IMPLEMENTATION_ICELAKE
  #ifdef SIMDUTF_NO_LIBCXX
static const icelake::implementation icelake_singleton{};
  #endif
static const icelake::implementation *get_icelake_singleton() {
  #ifdef SIMDUTF_NO_LIBCXX
  return &icelake_singleton;
  #else
  static const icelake::implementation icelake_singleton{};
  return &icelake_singleton;
  #endif
}
#endif

다음으로 simdutf는 각 백엔드를 추상 implementation 인터페이스의 하위 클래스로 모델링합니다. 이 설계 자체는 유지할 수 있지만, 추상 클래스의 vtable은 호출될 일이 없는 순수 가상 항목에 대해 여전히 __cxa_pure_virtual을 참조합니다. SIMDUTF_NO_LIBCXX 모드에서는 이 부분을 위한 아주 작은 로컬 심(shim)을 제공하고 런타임에 실제로 도달하지 않도록 했습니다. 이 심볼은 weak로 표시하여, C++ ABI가 존재할 경우 해당 정의로 덮어쓸 수 있도록 했습니다.

#ifdef SIMDUTF_NO_LIBCXX
// The abstract implementation vtable still carries pure-virtual slots even
// though correct dispatch never reaches them in this build mode. Provide the
// narrowest possible ABI shim so stricter no-libcxx objects do not require
// libc++abi just for this unreachable hook. Keep it weak so a toolchain's real
// libc++abi definition wins if one is linked in anyway.
extern "C" SIMDUTF_WEAK [[noreturn]] void __cxa_pure_virtual() noexcept {
  __builtin_trap();
}
#endif

마지막으로 -fno-exceptions-fno-rtti로 빌드를 감사하고 __cxa_guard_*, __gxx_personality, __cxa_throw, typeinfo, __dynamic_cast 같은 심볼이 전혀 나타나지 않는지 확인하는 스크립트를 작성했습니다. 이는 향후 NO_LIBCXX 빌드에서 실수로 libc++abi 의존성이 다시 생기는 일을 방지하기 위해 simdutf CI에 추가되었습니다.

검증

내부 검증

simdutf는 정확성과 성능이 매우 중요한 라이브러리이므로, 제 변경 사항이 두 가지 모두에 영향을 주지 않는다는 것을 확신해야 했습니다. 기존 테스트 및 벤치마크 스위트를 NO_LIBCXX 모드와 일반 모드 모두에서 실행되도록 수정하고, 모든 테스트를 통과하며 벤치마크에 영향이 없음을 확인했습니다.

중요한 점은 NO_LIBCXX 모드가 기존 테스트 및 벤치마크 스위트와 호환되도록 하는 데 필요한 변경 사항을 커밋했다는 것입니다. 덕분에 향후 simdutf 변경 사항에 대해서도 두 모드를 계속 검증할 수 있습니다.

외부 검증: Ghostty

다음으로 Ghostty가 제 포크의 새로운 simdutf를 사용하도록 업데이트하고, 빌드가 SIMDUTF_NO_LIBCXX를 사용하도록 변경했으며, 산출물에 libc++나 libc++abi 의존성이 없음을 검증하는 자체 테스트 스위트를 추가했습니다.

Ghostty에는 UTF-8 디코딩 동작(특히 잘못된 입력)을 검증하는 탄탄한 테스트 세트가 있습니다. 또한 다양한 시나리오에서 UTF-8 처리량을 테스트하는 내장 벤치마크 스위트도 갖추고 있습니다. 모든 Ghostty 테스트와 벤치마크를 실행해 모두 통과하고 예상대로 UTF-8 성능에 영향이 없음을 확인했습니다.

풀 리퀘스트

무언가를 동작하게 만드는 것과 머지시키는 것은 전혀 다른 일입니다.

저 역시 메인테이너로서 “동작한다”와 “머지할 수 있다” 사이의 간극을 너무나 잘 알고 있습니다. 타인의 작업을 검증하고 앞으로 유지보수할 수 있다는 확신을 갖는 것이 얼마나 어려운지 알고 있습니다. 큰 PR을 열면서 왜 열었는지 명확하지 않을 때의 어려움도 알고 있습니다. 그리고 최근 AI로 만든 저품질 결과물이 주는 부담도 잘 알고 있습니다.

그래서 저는 최고의 기여자에게 기대하는 수준의 노력을 기울여, simdutf 메인테이너들에게 그런 기여자가 되고자 했습니다.

먼저 전체 diff를 검토했습니다(네, 약 3,000줄 모두). 그리고 다시 검토했습니다. 전체 diff를 손으로 세네 번이나 다시 읽었습니다. 기능적으로는 문제가 없더라도 제가 직접 코멘트를 남겼을 만한 부분들을 기준으로 여러 차례 수정했습니다.

다음으로 동기, 접근 방식, 한계, 검증 과정을 설명하는 상세한 PR 설명을 직접 작성했습니다. 메인테이너들이 세부 내용을 파악하는 것은 물론, 제가 그 세부 사항에 얼마나 깊이 고민했는지도 알 수 있도록 하고 싶었습니다.

마지막으로 코드 작성에 AI의 도움을 받았다는 사실을 밝혔습니다. 하지만 모든 내용을 직접 검토했으며, PR 설명이나 코멘트 작성에는 AI를 사용하지 않았고, 제안한 모든 변경 사항을 인간으로서 직접 방어하고 수정할 수 있음을 분명히 했습니다.

아이러니하게도 전체 diff를 만드는 데는 약 2시간이 걸렸지만, 추가 검증 작업과 PR 준비에는 약 3시간이 걸렸습니다. 코드 자체보다 사람 사이의 경계에 더 많은 시간을 쓴 셈인데, 메인테이너들이 프로젝트에 쏟는 노력에 대한 존중 차원에서 마땅히 그래야 한다고 생각합니다.

최종 현황

simdutf PR은 아직 검토 중입니다. 초기 피드백은 긍정적이며, 요청되는 모든 변경 사항에 대해 열려 있습니다. 메인테이너들이 머지를 원하지 않을 가능성도 있지만, 그것도 괜찮습니다.

그동안 libc++나 libc++abi 없이 simdutf를 사용하고 싶다면 제 포크를 사용하면 됩니다. 단일 파일과 헤더를 통합해 빌드하는 방법에 대한 기존 설명서는 그대로 적용됩니다. C++를 빌드하고 헤더를 포함할 때 SIMDUTF_NO_LIBCXX를 정의하기만 하면 libc++ 없는 버전의 라이브러리를 얻을 수 있습니다.

Ghostty PR은 이제 머지되었습니다. 따라서 libghostty-vt는 SIMD 빌드에서 더 이상 libc++나 libc++abi에 의존하지 않습니다.

각주

  1. libc++는 C++ 표준 라이브러리(예: std::vector, std::string 등)이며, libc++abi는 C++ ABI 라이브러리(예: 예외 처리, RTTI 등)입니다.

  2. 참고로 SIMD를 비활성화하면 libghostty-vt는 libc조차 포함해 의존성이 전혀 없었습니다. 완전히 독립적인(freestanding) 라이브러리입니다.

원문은 Mitchell Hashimoto님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.