1P by GN⁺ | ★ favorite | 댓글 1개
  • {fmt}는 타입 소거로 템플릿 팽창을 줄여 온 C++ 포매팅 라이브러리이며, 이번 실험에서는 간단한 fmt::print 실행 파일을 75kB에서 14kB까지 줄임
  • 핵심 구조는 format이 템플릿이 아닌 vformat에 위임하고 출력 타입도 버퍼 API로 감추는 방식이라, 바이너리 크기와 빌드 시간을 함께 줄일 수 있음
  • aarch64 Ubuntu 22.04와 GCC 11.4.0에서 {fmt} 11.0.2의 stripped 실행 파일은 75kB였고, locale 비활성화와 내장 타입 축소, 크기 최적화 매크로로 71kB → 31kB → 27kB → 23kB까지 감소함
  • C++ 런타임 제거는 예외를 FMT_THROWabort 처리하고 -fno-exceptions, -nodefaultlibs, -lc로 빌드한 뒤, basic_memory_buffer의 기본 할당자를 malloc/free 기반으로 바꾸면서 가능해짐
  • 최종 실행 파일은 14kB이며, 같은 시스템의 빈 C main이 6kB인 점을 감안하면 {fmt}가 추가하는 크기는 10kB 미만이고 ldd에서도 C++ 런타임 의존성이 보이지 않음

{fmt}가 작은 바이너리를 만드는 방식

  • {fmt} formatting library는 IOStreams, Boost Format, tinyformat 같은 대안보다 함수 호출당 생성 코드가 여러 배 작을 때가 많음
  • 핵심은 여러 층에서 타입 소거(type erasure) 를 적용해 템플릿 팽창을 줄이는 구조에 있음
  • 포매팅 인자는 format_args로 타입 소거됨
    • format 템플릿 함수는 실제 작업을 템플릿이 아닌 vformat에 위임함
    • 출력 반복자와 다른 출력 타입도 별도 버퍼 API를 통해 타입 소거됨
  • 템플릿 사용은 최상위의 얇은 계층으로 제한되며, 이 구조가 더 작은 바이너리와 더 빠른 C++ 컴파일 시간에 기여함

printf에 가까운 코드 크기와 더 강한 안전성

  • 예제 프로그램은 fmt::print("The answer is {}.", 42);만 호출함
  • 컴파일 결과는 IOStreams보다 훨씬 작고, printf 예제와 비슷한 수준임
  • printf와 달리 {fmt}는 런타임 타입 안전성을 제공함
    • 포맷 문자열 오류는 컴파일 타임에 잡힐 수 있음
    • 포맷 문자열이 런타임에 정해져도 예외로 오류를 처리해 정의되지 않은 동작, 메모리 손상, 잠재적 크래시를 피함
  • C 가변 인자와 잘 맞지 않는 위치 인자(positional arguments) 를 사용할 때는 {fmt} 호출이 일반적으로 더 효율적임

기준 크기와 locale 제거

  • 2020년의 라이브러리 크기 최적화에서는 {fmt}를 100kB 미만, -Os -flto 기준 약 57kB까지 줄인 바 있음
  • 이후 {fmt}는 Junekey Jeon이 기여한 Dragonbox 알고리듬을 부동소수점 포매팅에 사용하게 됨
  • 이번 측정은 최종 사용자가 체감하는 실행 파일 크기를 기준으로 하며, aarch64 Ubuntu 22.04와 GCC 11.4.0에서 수행됨
  • {fmt} 11.0.2의 기준 빌드는 -Os -flto -DNDEBUGstrip 이후 75kB
    • 지난 4년간 여러 변화가 있었지만 크기는 크게 퇴보하지 않음
  • locale 지원을 FMT_STATIC_THOUSANDS_SEPARATOR로 비활성화하면 바이너리 크기가 71kB로 줄어듦
    • {fmt}의 포매팅은 기본적으로 locale 독립적임
    • locale은 L 포맷 지정자로 선택적으로 사용할 수 있음

내장 타입 축소와 “쓰지 않는 것은 비용을 내지 않는” 모델

  • Bloaty 분석에서는 숫자 포매팅, 특히 부동소수점 포매팅이 바이너리 크기의 큰 부분을 차지함
    • 부동소수점 포매팅은 표도 사용하며, 해당 표는 Bloaty 출력에 드러나지 않음
  • 근본적인 부담은 포매팅 함수가 포매팅 가능한 모든 타입을 알아야 한다는 점에서 생김
    • 이 방식은 C 표준의 printf에는 맞지만 {fmt}에는 필수 조건이 아님
    • {fmt}는 전체 타입 집합을 미리 알지 않아도 임의 타입을 포매팅할 수 있는 확장 API를 지원함
  • 실험 구현에서는 FMT_BUILTIN_TYPES=0을 설정해 int만 특별 처리하고 나머지 타입은 일반 확장 API로 보냄
    • int는 동적 폭과 정밀도 처리에 필요함
    • 예: fmt::print("{:{}}\n", "hello", 10);"hello "를 출력함
  • 이 방식은 쓰지 않는 타입의 비용을 내지 않는 모델을 제공하지만, 호출당 바이너리 크기는 약간 늘어남
    • 부동소수점이나 다른 타입을 실제로 포매팅하면 관련 코드는 여전히 빌드에 포함됨
  • FMT_BUILTIN_TYPES=0 적용 후 예제 바이너리는 31kB로 감소함
  • 이후 남아 있던 locale 관련 흔적을 e582d37b3ccc2d에서 제거하고, FMT_USE_LOCALE 매크로로 더 명확히 끌 수 있게 하면서 크기는 27kB가 됨

속도와 크기의 선택, 그리고 C++ 런타임 제거

  • 라이브러리 안에는 속도를 위해 크기를 쓰는 부분이 여러 곳 있음
  • 10진수 자릿수를 계산하는 do_count_digits는 256바이트 표를 사용함
    • 이 구현을 무조건 바꾸면 다른 사용 사례에 부정적 영향을 줄 수 있음
    • __builtin_clz를 사용할 수 없는 constexpr 같은 경우를 위한 fallback 구현도 이미 있음
  • FMT_OPTIMIZE_SIZE 매크로를 추가해 사용자가 fallback 구현 사용 여부를 제어할 수 있게 함
    • 이 조정과 몇 가지 유사한 변경으로 바이너리 크기는 23kB가 됨
  • C++ 표준 라이브러리 의존성을 없애기 위해 예외는 FMT_THROW로 비활성화할 수 있음
    • 예시는 FMT_THROW(s)=abort()-fno-exceptions를 사용함
    • 일반적으로 권장되지는 않지만, 대부분의 오류가 컴파일 타임에 잡히는 일부 사용 사례에서는 괜찮을 수 있음
  • -nodefaultlibs -lc로 빌드하면 남은 C++ 런타임 의존성은 fmt::basic_memory_buffer에서 발생함
    • 이 버퍼는 작은 스택 할당 버퍼이며 필요하면 동적 메모리로 확장됨
    • fmt::print는 일반적으로 FILE 버퍼에 직접 쓸 수 있어 동적 할당이 필요하지 않음
  • 더 일반적인 해법으로 기본 할당자를 new/delete 대신 malloc/free 기반으로 교체함
    • 이 변경 후 최종 바이너리 크기는 14kB가 됨
    • 같은 시스템의 빈 C main 프로그램은 6kB이므로 {fmt}가 추가하는 크기는 10kB 미만임
  • ldd a.out 결과는 libc.so.6과 로더만 보이며, C++ 런타임 의존성은 나타나지 않음
  • 최종 결과는 임베디드와 메모리 제약 환경에서 {fmt}를 더 작게 사용할 수 있음을 보여줌

댓글과 토론

Hacker News 의견들
  • 이건 사실 위원회 성향에 가까운 문제라, 서드파티 라이브러리인 fmt가 굳이 잘못된 기본값을 가질 거라고 기대하진 않음
    놀랍게도 이 기능이 C++20의 std::format으로 표준화될 때 위원회가 표준의 다른 여러 부분에 있는 이 실수를 다시 넣지 않았음
    그래서 C++를 “일관되게” 만들겠다는 이유로 불필요하게 더 나쁘게 만들지 말라고 호소하는 제안자들에게도 약간의 희망은 있음

  • 부동소수점 포맷팅에 필요한 코드량을 보면 꽤 충격적임
    링크된 Dragonbox [1] 프로젝트도 읽어볼 만하고, 거의 쓰이지 않는 분기까지 꽤 최적화되어 있음
    [1] https://github.com/jk-jeon/dragonbox

    • 최근 Zig 작업을 하면서 부동소수점 포맷팅에 얼마나 많은 코드가 필요한지 알게 됨
      보통 Zig 컴파일러는 Windows에서 C 런타임에 의존하지 않기 때문에 MSVC보다 작은 바이너리를 만들 수 있는데, 이번에는 도구가 하는 일에 비해 바이너리가 이상하게 컸음
      Binary Ninja로 열어보니 코드 대부분이 부동소수점 포맷팅 지원용이었고, 출력 전에 부동소수점 수를 정수로 캐스팅하자 기대하던 크기까지 줄어들었음
    • https://github.com/jk-jeon/dragonbox/discussions/57#discussioncomment-9340182
      크기 최적화 실험을 하고 있고, 현재는 8비트 AVR에서 약 3k까지 줄일 수 있음
      단정밀도 binary32용 구현과 테이블만 포함한 상태이고 배정밀도는 훨씬 더 필요하지만, 동시에 비대해지는 상당 부분은 AVR의 제약 때문임
      x64 같은 플랫폼에서는 훨씬 작아질 수 있지만, 3k도 여전히 크다고 말할 수는 있음
    • 빠르게 만들고 싶다면 코드가 많이 필요함
      기준 구현도 결국 임의 정밀도 산술 구현이긴 하지만 그렇게 나쁘진 않음
      [1] https://research.swtch.com/ftoa
      [2] https://go.dev/src/strconv/ftoa.go
    • {fmt}에는 오래된 Dragon4 알고리즘의 선택적 구현이 있고, 코드 크기는 더 작지만 속도는 더 느림
    • 대부분의 사용 사례는 출력할 소수 자릿수를 제한할 것 같음
      소수 자릿수만큼 곱한 뒤 정수로 변환하고, itoa() 를 거친 다음 적절한 위치에 소수점을 삽입하는 방식이 더 효율적일지 궁금함
  • C++ 초보 입장에서 궁금한데, libc++의 기본 할당자, 즉 기본 new/delete 구현이 내부적으로 libc의 malloc/free를 호출하는 것과 실제로 다른 일을 하나? 그렇다면 왜 그런가?

    • C++에 아주 강한 건 아니지만, new[] 는 메모리를 얻기 위해 new 연산자를 호출한 뒤 각 요소의 생성자를 실행하려고 함
      delete[] 는 메모리를 해제하기 전에 각 요소의 소멸자를 실행하려고 함
      delete[]가 동작하려면 C++는 어딘가에 할당 크기를 추적해야 하고, 이 정보는 할당 영역 근처에 둘 수도 있고 별도 구조에 둘 수도 있음
      별도 구조를 쓰면 객체 뒤쪽 메모리를 잘못 쓰는 경우 정보가 덮일 가능성은 낮아지지만 조회 비용과 추가 코드가 필요함
      제대로 된 C++ 라이브러리는 더 많은 일을 하겠지만, new/delete가 malloc/free와 같지 않다는 감은 잡을 수 있음
    • ISO C++는 new/delete의 기본 구현이 malloc()/free() 를 호출해야 한다고 요구하지 않음
      많은 구현이 그렇게 하는 건 이미 존재하고 사용하기 쉬워서일 뿐임
    • 정렬 할당 오버로드를 제외하면 기본적으로는 다르지 않음
      다만 애플리케이션은 ELF 심볼 인터포지션에 해당하는 기능이 없는 플랫폼에서도 기본 표준 라이브러리 operator new를 자체 구현으로 대체할 수 있음
    • malloc으로 바꾸는 핵심 이유는 new가 std::bad_alloc을 던지기 때문에, 이를 쓰면 C++ 런타임에 링크해야 하기 때문임
  • 작고 문자열과 정수를 출력할 수 있게 설계된 포맷팅 라이브러리라면 대략 50바이트 정도여야 하지 않을까 기대했음
    문자열은 널 종료 문자 검사, 문자 출력, 두 단계 뒤로 분기 정도로 약 4개 명령이면 됨
    정수는 음수 확인 후 '-' 출력과 부호 반전, 1000000000을 R1에 넣고 나누기와 나머지 저장, ASCII '0' 더하기, 문자 출력, R1을 10으로 나누기, 나머지를 입력으로 넣기, R1=0이 될 때까지 반복 정도라 약 20개 명령이면 됨
    부동소수점은 많은 프로그램에서 쓰지 않으니 필요할 때만 컴파일되어야 하고, 16진수·포인터·앞쪽 0 채우기도 마찬가지임
    코드 공간이 2KB인 마이크로컨트롤러용 코드를 쓸 때 14KB짜리 문자열 포맷팅 라이브러리를 넣지는 않음

    • 이건 수정자 없는 느린 정수·문자열 출력 라이브러리가 아니라 기능이 많은 포맷팅 라이브러리
      기능이 많고 빠르고 작기까지 한 라이브러리를 동시에 만들 수는 없음
    • 마이크로컨트롤러용 라이브러리 설계와 일반 최종 사용자 애플리케이션용 “동등한” 라이브러리 설계는 거의 모든 주요 지점에서 달라짐
      이게 fmt에만 해당하는 얘기라기보다 공개된 자리에서 하는 일반적인 불평과 뭐가 다른지 잘 모르겠음
      Dragonbox나 Dragon4 같은 알고리즘 코드만으로도 이미 크기 예산을 넘기기 때문에 “선택적” 기능은 크게 중요하지 않음
      그리고 그건 사람들이 원하는 20개쯤 되는 기능 중 하나일 뿐임
    • 그렇다면 실제로 쓰는 라이브러리를 공개하고, 어떤 포맷팅 기능을 지원하는지 문서화하는 게 맞지 않나 싶음
      그러면 다른 사람들이 더 많은 기능을 더 영리하게 집어넣는 방법을 찾아낼 수도 있음
      그렇지 않다면 요점을 잘 모르겠음
    • 특정 프로그래밍 틈새 분야의 요구사항이 언어에 그런 식으로 영향을 줘서는 안 된다고 봄
      그 요구사항은 타당하지만, 언어 명세가 아니라 최저 사양 마이크로컨트롤러 컴파일러가 해결해야 할 일임
    • 이 라이브러리는 작게 만드는 것이 주목적이 아니라, 완전한 문자열 포맷팅 라이브러리를 만들되 크기를 중요한 부차 목표로 둔 것임
      기본 기능도 지원하지 않는 대신 극도로 작아야 한다면 더 나은 선택지는 분명 있음
      코드 공간이 2KB뿐이라면 이걸 쓰면 안 됨
      다행히 현대 마이크로컨트롤러 대부분은 그보다 훨씬 크고, 예를 들어 esp32는 1MB부터 시작하므로 14KB 포맷팅 라이브러리를 쓰는 것도 충분히 합리적임
  • 홍보를 조금 하자면, 출력 버퍼링이 있는 libc를 포함해도 1008바이트 실행 파일printf(Hello, World!\n");가 가능함: https://github.com/pts/minilibc686
    물론 직접 비교하면 사과와 오렌지를 비교하는 셈임

    • 그건 컴파일러가 이를 fputs로 바꾸기 때문임
  • “빈 main 함수가 있는 C 프로그램이 이 시스템에서 6kB라면, {fmt}는 이제 바이너리에 10kB 미만만 추가한다”는 부분이 흥미로움
    이런 테스트는 해본 적이 없음

    • C 라이브러리를 동적 링크하느냐 정적 링크하느냐, 애플리케이션과 C 라이브러리를 어떻게 빌드했느냐에 따라 크게 달라짐
      어떤 C 라이브러리를 쓰는지도 중요하고, ELF를 쓰는지 다른 컨테이너를 쓰는지도 약간 영향을 줌
  • 항상 fmt가 문제임
    충분히 많은 숫자, 특히 부동소수점과 decimal 포맷팅/파싱을 건드리면 링커가 부동소수점과 BigInt 관련 코드를 많이 끌어와 바이너리 크기가 커지는 일이 이제 .NET에서도 똑같이 벌어진다는 게 엄청 웃김

    • Native AOT에서도 Delphi 같은 경험을 아직 기대하고 있고, 다행히 점점 나아지고 있음
  • 아주 재미있음
    이런 발상의 전환식 최적화를 좋아함

  • 내가 느린 건지 모르겠지만, 제목의 “14k”가 14kB를 뜻한다는 걸 깨닫는 데 시간이 좀 걸렸음

    • 달리 뭘 의미할 수 있겠나 싶음
      적어도 역사적으로 k는 kB의 흔한 줄임말임