Библиотека форматирования {fmt} известна своим небольшим влиянием на размер бинарников. Чаще всего её код в несколько раз меньше по сравнению с такими библиотеками, как IOStreams, Boost Format или, что иронично, tinyformat. Это достигается за счет аккуратного применения стирания типов на разных уровнях, что минимизирует излишнее использование шаблонов.

Аргументы форматирования передаются через format_args со стертыми типами:

auto vformat(string_view fmt, format_args args) -> std::string;

template <typename... T>
auto format(format_string<T...> fmt, T&&... args) -> std::string {
  return vformat(fmt, fmt::make_format_args(args...));
}

Как можно заметить, format делегирует всю работу, не являющейся шаблоном, vformat.

Для итераторов вывода и других типов вывода типы стираются с помощью специального API буфера.

Подобный подход снижает использование шаблонов до минимального верхнеуровневого слоя, одновременно уменьшая размер бинарника и время компиляции.

Например, следующий код:

// test.cc
#include <fmt/base.h>

int main() {
  fmt::print("The answer is {}.", 42);
}

Компилируется до

.LC0:
        .string "The answer is {}."
main:
        sub     rsp, 24
        mov     eax, 1
        mov     edi, OFFSET FLAT:.LC0
        mov     esi, 17
        mov     rcx, rsp
        mov     rdx, rax
        mov     DWORD PTR [rsp], 42
        call    fmt::v11::vprint(fmt::v11::basic_string_view<char>, fmt::v11::basic_format_args<fmt::v11::context>)
        xor     eax, eax
        add     rsp, 24
        ret

Он значительно меньше эквивалентного кода IOStreams и сравним с printf:

.LC0:
        .string "The answer is %d."
main:
        sub     rsp, 8
        mov     esi, 42
        mov     edi, OFFSET FLAT:.LC0
        xor     eax, eax
        call    printf
        xor     eax, eax
        add     rsp, 8
        ret

В отличие от printf, {fmt} предоставляет полную типобезопасность в рантайме. Ошибки в строках форматирования могут быть пойманы еще на этапе компиляции, но даже если строки определяются в рантайме, ошибки обрабатываются как исключения, предотвращая неопределенное поведение, повреждение памяти и потенциальные вылеты. Также в целом вызовы {fmt} более эффективны, особенно когда используются позиционные аргументы, для чего C varargs не очень подходят.

В 2020 году я посвятил некоторое количество времени оптимизации размера библиотеки, успешно уменьшив ее размер до менее 100 kB (всего ~57 kB при использовании -Os -flto). Многое изменилось с тех пор. В частности, {fmt} теперь использует выдающийся алгоритм Dragonbox для форматирования чисел с плавающей запятой, любезно предоставленный его автором Junekey Jeon. Давайте посмотрим, как эти изменения повлияли на размер бинарного файла и можно ли уменьшить его еще больше.

Кто‑то может спросить: почему именно размер бинарного файла?

Использование {fmt} на устройствах с ограниченным размером памяти вызвало значительный интерес — в частности, эти примеры из далекого прошлого: #758 и #1226. Особенно интригующий кейс — программирование под ретро‑машины, где люди используют {fmt} для таких систем, как Amiga (#4054).

Мы будем использовать ту же методологию, что и раньше, исследуя размер исполняемого файла программы, использующей {fmt}, поскольку это будет наиболее релевантно для большинства пользователей. Все тесты будут проводиться на платформе с aarch64 Ubuntu 22.04 и GCC 11.4.0.

В первую очередь определим начальную точку: какой размер бинарного файла у последней версии {fmt} (11.0.2)?

$ git checkout 11.0.2
$ g++ -Os -flto -DNDEBUG -I include test.cc src/format.cc
$ strip a.out && ls -lh a.out
-rwxrwxr-x 1 vagrant vagrant 75K Aug 30 19:24 a.out

Размер полученного файла — 75 kB. Из положительного можно отметить, что, несмотря на значительное количество изменений за последние 4 года, размер значительно не увеличился.

Пришло время рассмотреть возможные пути оптимизации. Одним из первых кандидатов может быть отключение поддержки локалей. Все форматирование в {fmt} локале‑независимое по умолчанию (что нарушается из‑за традиции C++ иметь некорректные значения по умолчанию), но все еще доступно с помощью спецификатора формата L. Его можно отключить, используя макрос FMT_STATIC_THOUSANDS_SEPARATOR:

$ g++ -Os -flto -DNDEBUG "-DFMT_STATIC_THOUSANDS_SEPARATOR=','" \
      -I include test.cc src/format.cc
$ strip a.out && ls -lh a.out
-rwxrwxr-x 1 vagrant vagrant 71K Aug 30 19:25 a.out

Отключение поддержки локалей уменьшает размер бинарного файла до 71 kB.

Теперь проверим результат с помощью нашего верного инструмента — Bloaty:

$ bloaty -d symbols a.out

    FILE SIZE        VM SIZE
 --------------  --------------
  43.8%  41.1Ki  43.6%  29.0Ki    [121 Others]
   6.4%  6.04Ki   8.1%  5.42Ki    fmt::v11::detail::do_write_float<>()
   5.9%  5.50Ki   7.5%  4.98Ki    fmt::v11::detail::write_int_noinline<>()
   5.7%  5.32Ki   5.8%  3.88Ki    fmt::v11::detail::write<>()
   5.4%  5.02Ki   7.2%  4.81Ki    fmt::v11::detail::parse_replacement_field<>()
   3.9%  3.69Ki   3.7%  2.49Ki    fmt::v11::detail::format_uint<>()
   3.2%  3.00Ki   0.0%       0    [section .symtab]
   2.7%  2.50Ki   0.0%       0    [section .strtab]
   2.3%  2.12Ki   2.9%  1.93Ki    fmt::v11::detail::dragonbox::to_decimal<>()
   2.0%  1.89Ki   2.4%  1.61Ki    fmt::v11::detail::write_int<>()
   2.0%  1.88Ki   0.0%       0    [ELF Section Headers]
   1.9%  1.79Ki   2.5%  1.66Ki    fmt::v11::detail::write_float<>()
   1.9%  1.78Ki   2.7%  1.78Ki    [section .dynstr]
   1.8%  1.72Ki   2.4%  1.62Ki    fmt::v11::detail::format_dragon()
   1.8%  1.68Ki   1.5%    1016    fmt::v11::detail::format_decimal<>()
   1.6%  1.52Ki   2.1%  1.41Ki    fmt::v11::detail::format_float<>()
   1.6%  1.49Ki   0.0%       0    [Unmapped]
   1.5%  1.45Ki   2.2%  1.45Ki    [section .dynsym]
   1.5%  1.45Ki   2.0%  1.31Ki    fmt::v11::detail::write_loc()
   1.5%  1.44Ki   2.2%  1.44Ki    [section .rodata]
   1.5%  1.40Ki   1.1%     764    fmt::v11::detail::do_write_float<>()::{lambda()#2}::operator()()
 100.0%  93.8Ki 100.0%  66.6Ki    TOTAL

Значительную часть бинарного файла занимает код, посвященный форматированию чисел, в особенности — чисел с плавающей запятой. Форматирование последних также полагается на использование больших таблиц, не показанных здесь. Но что, если поддержка чисел с плавающей запятой нам не требуется? {fmt} позволяет отключить ее, хотя метод отключения немного спонтанный и не распространяется на другие типы.

Основная проблема в том, что функции форматирования должны знать о всех форматируемых типах. Но действительно ли это так? Это верно для printf, определяемой стандартом C, но необязательно для {fmt}. {fmt} поддерживает API расширений, позволяющий форматирование произвольных типов, не зная их полного набора заранее. В то время как встроенные и строковые типы обрабатываются целенаправленно для оптимизации скорости работы, при оптимизации размера файла может потребоваться другой подход. Убирая специальную обработку и обрабатывая каждый тип с помощью API расширений, мы можем избежать оверхэда от неиспользуемых типов.

Я создал экспериментальную реализацию этой задумки. Задав значение 0 для макроса FMT_BUILTIN_TYPES, только int обрабатывается целенаправленно, а все остальные типы проходят через API расширений. Нам все еще необходимо знать про int для динамической ширины и точности, например:

fmt::print("{:{}}\n", "hello", 10); // prints "hello     "

Это дает нам модель «не платим за то, что не используем», хотя это немного увеличивает размер каждого вызова в бинарном файле. Если вы обрабатываете числа с плавающей запятой или другие типы, соответствующий код все еще будет включен в итоговую сборку. И хотя реализацию обработки чисел с плавающей запятой можно уменьшить, мы не будем касаться этой темы в этой статье.

Используя FMT_BUILTIN_TYPES=0, размер бинарного файла уменьшился до 31 kB, что дает нам значительное улучшение:

$ git checkout 377cf20
$ g++ -Os -flto -DNDEBUG \
      "-DFMT_STATIC_THOUSANDS_SEPARATOR=','" -DFMT_BUILTIN_TYPES=0 \
      -I include test.cc src/format.cc
$ strip a.out && ls -lh a.out
-rwxrwxr-x 1 vagrant vagrant 31K Aug 30 19:37 a.out

Однако обновленные результаты Bloaty показывают, что некоторые артефакты локалей все еще сохраняются, например, digit_grouping:

$ bloaty -d fullsymbols a.out

    FILE SIZE        VM SIZE
 --------------  --------------
  41.8%  18.0Ki  39.7%  11.0Ki    [84 Others]
   6.4%  2.77Ki   0.0%       0    [section .symtab]
   5.3%  2.28Ki   0.0%       0    [section .strtab]
   4.6%  1.99Ki   6.9%  1.90Ki    fmt::v11::detail::format_handler<char>::on_format_specs(int, char const*, char const*)
   4.4%  1.88Ki   0.0%       0    [ELF Section Headers]
   4.1%  1.78Ki   5.8%  1.61Ki    fmt::v11::basic_appender<char> fmt::v11::detail::write_int_noinline<char, fmt::v11::basic_appender<char>, unsigned int>(fmt::v11::basic_appender<char>, fmt::v11::detail::write_int_arg<unsigned int>, fmt::v11::format_specs const&, fmt::v11::detail::locale_ref) (.constprop.0)
   3.7%  1.60Ki   5.8%  1.60Ki    [section .dynstr]
   3.5%  1.50Ki   4.8%  1.34Ki    void fmt::v11::detail::vformat_to<char>(fmt::v11::detail::buffer<char>&, fmt::v11::basic_string_view<char>, fmt::v11::detail::vformat_args<char>::type, fmt::v11::detail::locale_ref) (.constprop.0)
   3.5%  1.49Ki   4.9%  1.35Ki    fmt::v11::basic_appender<char> fmt::v11::detail::write_int<fmt::v11::basic_appender<char>, unsigned __int128, char>(fmt::v11::basic_appender<char>, unsigned __int128, unsigned int, fmt::v11::format_specs const&, fmt::v11::detail::digit_grouping<char> const&)
   3.1%  1.31Ki   4.7%  1.31Ki    [section .dynsym]
   3.0%  1.29Ki   4.2%  1.15Ki    fmt::v11::basic_appender<char> fmt::v11::detail::write_int<fmt::v11::basic_appender<char>, unsigned long, char>(fmt::v11::basic_appender<char>, unsigned long, unsigned int, fmt::v11::format_specs const&, fmt::v11::detail::digit_grouping<char> const&)

После отключения этих артефактов в коммитах e582d37 и b3ccc2d, а также добавления более удобной опции для отказа через макрос FMT_USE_LOCALE, размер бинарника уменьшился до 27 kB:

$ git checkout b3ccc2d
$ g++ -Os -flto -DNDEBUG -DFMT_USE_LOCALE=0 -DFMT_BUILTIN_TYPES=0 \
      -I include test.cc src/format.cc
$ strip a.out && ls -lh a.out
-rwxrwxr-x 1 vagrant vagrant 27K Aug 30 19:38 a.out

В библиотеке есть несколько мест, где размером жертвуют в пользу скорости. Например, рассмотрим функцию вычисления количества десятичных цифр:

auto do_count_digits(uint32_t n) -> int {
// An optimization by Kendall Willets from https://bit.ly/3uOIQrB.
// This increments the upper 32 bits (log10(T) - 1) when >= T is added.
#  define FMT_INC(T) (((sizeof(#T) - 1ull) << 32) - T)
  static constexpr uint64_t table[] = {
      FMT_INC(0),          FMT_INC(0),          FMT_INC(0),           // 8
      FMT_INC(10),         FMT_INC(10),         FMT_INC(10),          // 64
      FMT_INC(100),        FMT_INC(100),        FMT_INC(100),         // 512
      FMT_INC(1000),       FMT_INC(1000),       FMT_INC(1000),        // 4096
      FMT_INC(10000),      FMT_INC(10000),      FMT_INC(10000),       // 32k
      FMT_INC(100000),     FMT_INC(100000),     FMT_INC(100000),      // 256k
      FMT_INC(1000000),    FMT_INC(1000000),    FMT_INC(1000000),     // 2048k
      FMT_INC(10000000),   FMT_INC(10000000),   FMT_INC(10000000),    // 16M
      FMT_INC(100000000),  FMT_INC(100000000),  FMT_INC(100000000),   // 128M
      FMT_INC(1000000000), FMT_INC(1000000000), FMT_INC(1000000000),  // 1024M
      FMT_INC(1000000000), FMT_INC(1000000000)                        // 4B
  };
  auto inc = table[__builtin_clz(n | 1) ^ 31];
  return static_cast<int>((n + inc) >> 32);
}

Используемая здесь таблица занимает 256 байт. Универсального решения не существует, и внесение изменений может негативно сказаться на других ситуациях. К счастью, есть другая реализация этой функции для случаев, когда __builtin_clz недоступен, например, constexpr:

template <typename T> constexpr auto count_digits_fallback(T n) -> int {
  int count = 1;
  for (;;) {
    // Integer division is slow so do it for a group of four digits instead
    // of for every digit. The idea comes from the talk by Alexandrescu
    // "Three Optimization Tips for C++". See speed-test for a comparison.
    if (n < 10) return count;
    if (n < 100) return count + 1;
    if (n < 1000) return count + 2;
    if (n < 10000) return count + 3;
    n /= 10000u;
    count += 4;
  }
}

Остается лишь предоставить пользователям возможность выбирать, использовать ли альтернативную реализацию. Как вы можете догадаться, это реализовано с помощью еще одного конфигурационного макроса FMT_OPTIMIZE_SIZE:

auto count_digits(uint32_t n) -> int {
#ifdef FMT_BUILTIN_CLZ
  if (!is_constant_evaluated() && !FMT_OPTIMIZE_SIZE) return do_count_digits(n);
#endif
  return count_digits_fallback(n);
}

С помощью этого и нескольких подобных изменений мы сократили размер бинарника до 23 kB:

$ git checkout 8e3da9d
$ g++ -Os -flto -DNDEBUG -I include \
      -DFMT_USE_LOCALE=0 -DFMT_BUILTIN_TYPES=0 -DFMT_OPTIMIZE_SIZE=1 \
      test.cc src/format.cc
$ strip a.out && ls -lh a.out
-rwxrwxr-x 1 vagrant vagrant 23K Aug 30 19:41 a.out

Мы скорее всего можем снизить размер бинарного файла еще сильнее, поиграв с настройками, но давайте обратим наш взор на нечто более глобальное — стандартную библиотеку C++. Какой смысл оптимизировать размер, когда в итоге мы получаем 1–2 мегабайта рантайма C++?

Хотя {fmt} и старается полагаться как можно меньше на стандартную библиотеку, можем ли мы совсем убрать эту зависимость? Одна из очевидных проблем — исключения, их мы можем отключить с помощью FMT_THROW, например указав значение abort. В целом это не рекомендуется делать, но может подойти в ряде случаев, учитывая что большая часть ошибок отлавливается на этапе компиляции.

Попробуем скомпилировать с флагом ‑nodefaultlibs и отключенными исключениями:

$ g++ -Os -flto -DNDEBUG -I include \
      -DFMT_USE_LOCALE=0 -DFMT_BUILTIN_TYPES=0 -DFMT_OPTIMIZE_SIZE=1 \
      '-DFMT_THROW(s)=abort()' -fno-exceptions test.cc src/format.cc \
      -nodefaultlibs -lc

/usr/bin/ld: /tmp/cc04DFeK.ltrans0.ltrans.o: in function `fmt::v11::basic_memory_buffer<char, 500ul, std::allocator<char> >::grow(fmt::v11::detail::buffer<char>&, unsigned long)':
<artificial>:(.text+0xaa8): undefined reference to `std::__throw_bad_alloc()'
/usr/bin/ld: <artificial>:(.text+0xab8): undefined reference to `operator new(unsigned long)'
/usr/bin/ld: <artificial>:(.text+0xaf8): undefined reference to `operator delete(void*, unsigned long)'
/usr/bin/ld: /tmp/cc04DFeK.ltrans0.ltrans.o: in function `fmt::v11::vprint_buffered(_IO_FILE*, fmt::v11::basic_string_view<char>, fmt::v11::basic_format_args<fmt::v11::context>) [clone .constprop.0]':
<artificial>:(.text+0x18c4): undefined reference to `operator delete(void*, unsigned long)'
collect2: error: ld returned 1 exit status

На удивление, подобный подход почти сработал. Единственная зависимость от рантайма C++ идет от fmt::basic_memory_buffer — небольшого буфера на стеке, который может расширяться в динамическую память при необходимости.

fmt::print может писать напрямую в буфер FILE и в целом не требует динамического выделения памяти, так что мы могли бы убрать зависимость от fmt::basic_memory_buffer из fmt::print. Но поскольку он может быть использован где‑то в другом месте, более корректным подходом будет замена стандартного аллокатора на использование malloc и free вместо new и delete.

template <typename T> struct allocator {
  using value_type = T;

  T* allocate(size_t n) {
    FMT_ASSERT(n <= max_value<size_t>() / sizeof(T), "");
    T* p = static_cast<T*>(malloc(n * sizeof(T)));
    if (!p) FMT_THROW(std::bad_alloc());
    return p;
  }

  void deallocate(T* p, size_t) { free(p); }
};

Это уменьшает размер бинарника до 14 kB:

$ git checkout c0fab5e
$ g++ -Os -flto -DNDEBUG -I include \
      -DFMT_USE_LOCALE=0 -DFMT_BUILTIN_TYPES=0 -DFMT_OPTIMIZE_SIZE=1 \
      '-DFMT_THROW(s)=abort()' -fno-exceptions test.cc src/format.cc \
      -nodefaultlibs -lc
$ strip a.out && ls -lh a.out
-rwxrwxr-x 1 vagrant vagrant 14K Aug 30 19:06 a.out

Учитывая, что программа на C с пустой функцией main занимает 6 kB в используемой системе, {fmt} добавляет меньше 10 kB к размеру бинарника.

Мы также можем убедиться, что больше не зависим от рантайма C++:

$ ldd a.out
        linux-vdso.so.1 (0x0000ffffb0738000)
        libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffffb0530000)
        /lib/ld-linux-aarch64.so.1 (0x0000ffffb06ff000)

Надеюсь, вам было интересно, и желаю удачи со встроенным форматированием!

Комментарии (0)