- {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_THROW로abort처리하고-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 -DNDEBUG와strip이후 75kB임- 지난 4년간 여러 변화가 있었지만 크기는 크게 퇴보하지 않음
- locale 지원을
FMT_STATIC_THOUSANDS_SEPARATOR로 비활성화하면 바이너리 크기가 71kB로 줄어듦- {fmt}의 포매팅은 기본적으로 locale 독립적임
- locale은
L포맷 지정자로 선택적으로 사용할 수 있음
내장 타입 축소와 “쓰지 않는 것은 비용을 내지 않는” 모델
- Bloaty 분석에서는 숫자 포매팅, 특히 부동소수점 포매팅이 바이너리 크기의 큰 부분을 차지함
- 부동소수점 포매팅은 표도 사용하며, 해당 표는 Bloaty 출력에 드러나지 않음
- 근본적인 부담은 포매팅 함수가 포매팅 가능한 모든 타입을 알아야 한다는 점에서 생김
- 이 방식은 C 표준의
printf에는 맞지만 {fmt}에는 필수 조건이 아님 - {fmt}는 전체 타입 집합을 미리 알지 않아도 임의 타입을 포매팅할 수 있는 확장 API를 지원함
- 이 방식은 C 표준의
- 실험 구현에서는
FMT_BUILTIN_TYPES=0을 설정해int만 특별 처리하고 나머지 타입은 일반 확장 API로 보냄int는 동적 폭과 정밀도 처리에 필요함- 예:
fmt::print("{:{}}\n", "hello", 10);는"hello "를 출력함
- 이 방식은 쓰지 않는 타입의 비용을 내지 않는 모델을 제공하지만, 호출당 바이너리 크기는 약간 늘어남
- 부동소수점이나 다른 타입을 실제로 포매팅하면 관련 코드는 여전히 빌드에 포함됨
FMT_BUILTIN_TYPES=0적용 후 예제 바이너리는 31kB로 감소함- 이후 남아 있던 locale 관련 흔적을 e582d37과 b3ccc2d에서 제거하고,
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}를 더 작게 사용할 수 있음을 보여줌