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

Mitchell Hashimoto

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

원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기

이번 PR을 기점으로, simdutf를 이제 libc++나 libc++abi 없이 사용할 수 있게 됐다1.

libghostty-vt에서 simdutf는 남아 있던 마지막 libc++ 의존성이었다2. Ghostty를 새로운 simdutf 빌드를 사용하도록 업데이트한 뒤, 의존성에서 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를 깨야 했다. 그 외에는 완전히 유지된다.

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 모드에서는 아주 작은 로컬 심을 제공하고 런타임에 실제로 도달하지 않도록 했다. 이 심볼은 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를 정의하기만 하면 라이브러리의 no-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한 라이브러리다.

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

댓글