Производительность компилятора при работе с концептами в C++20

Kate

Administrator
Команда форума
655b9a55c303ffa9b92e35bb54dcd481.png

Привет, меня зовут Александр, я старший разработчик ПО в Центре разработки Orion Innovation. Хочу признаться, я люблю рассказывать про C++ и не только на различных митапах и конференциях. И вот я добрался до Хабра. На CppConf Russia Piter 2020 я рассказывал про концепты и после выступления получил очень много вопросов про производительность компилятора при работе с ними. Замеры производительности не были целью моего доклада: мне было известно, что концепты компилируются с примерно такой же скоростью, что и обычные метапрограммы, а до детального сравнения я смог добраться совершенно недавно. Спешу поделиться результатом!

Несколько слов о концептах​

Концепты — переосмысление метапрограммирования, аналогичное constexpr. Если constexpr — это про вычисление выражений во время компиляции, будь то факториал, экспонента и так далее, то концепты — это про перегрузки, специализации, условия существования сущностей. В общем, про «чистое метапрограммирование». Иными словами, в C++20 появилась возможность писать конструкции без единой, привычной для нас треугольной скобки, тем самым получая возможность быстро и читаемо описать какую-либо перегрузку или специализацию:

// #1
void increment(auto & arg) requires requires { ++arg; };
// #2
void increment(auto &);

struct Incrementable { Incrementable & operator++() { return *this; } };
struct NonIncrementable {};

void later() {
Incrementable i;
NonIncrementable ni;
increment(i); // Вызывается #1
increment(ni); // Вызывается #2
}

О том, как всё это работает, есть море информации, например, отличный гайд "Концепты: упрощаем реализацию классов STD Utility" по мотивам выступления Андрея Давыдова на C++ Russia 2019. Ну а мы сфокусируемся на том, какой ценой достигается подобный функционал, чтобы убедиться, что это не только просто, быстро и красиво, но ещё и эффективно.

Описание эксперимента​

Итак, мы будем наблюдать за следующими показателями:

  1. Время компиляции
  1. Размер объектного файла
  1. Количество символов в записи (или же количество кода), в некоторых случаях
Прежде чем мы начнём несколько важных уточнений:

  • Во-первых, при подсчёте количества символов в записи мы будем считать все не пустые.
  • Во-вторых, в данной статье мы посмотрим лишь на самые простые (буквально несколько строк) случаи, чтобы быть уверенными на 100%, что мы сравниваем абсолютно аналогичные фрагменты кода.
  • В-третьих, поскольку компилируемые примеры крайне просты, время компиляции выражается в десятках миллисекунд. Чтобы исключить погрешность, для времени компиляции мы будем использовать усреднённые значения за 100 запусков.
В замерах будут участвовать clang 12.0.0 и g++ 10.3.0, как с полной оптимизацией, так и без неё.

В качестве операционной системы выступит Ubuntu 16.04, запущенная на Windows 10 через WSL2. На всякий случай прилагаю характеристики ПК:

Характеристики ПК

Эксперименты​

После необходимых отступлений мы можем, наконец, начать эксперименты.

Эксперимент №1: Эволюция метапрограммирования​

Для начала посмотрим на то, как компиляторы справляются с созданием перегрузки функции для инкрементируемых и неинкрементируемых типов данных аргумента. Компилируемый код для C++ 03, 17 и 20 представлены ниже. Один из показателей, а именно — объем кода, можно оценить уже сейчас: видно, что количество кода существенно сокращается по мере эволюции языка, уступая место читаемости и простоте.

Код
Давайте взглянем на результаты:

ФайлКомпиляцияВремя, мсРазмер объектного файла, байтКоличество символов, шт
incrementable_03.cppclangO043,021304782
incrementable_17.cppclangO067,461320472
incrementable_20.cppclangO043,421304230
incrementable_03.cppclangO347,211296782
incrementable_17.cppclangO377,771304472
incrementable_20.cppclangO345,701288230
incrementable_03.cppgccO019,891568782
incrementable_17.cppgccO034,711568472
incrementable_20.cppgccO017,621480230
incrementable_03.cppgccO318,441552782
incrementable_17.cppgccO338,941552472
incrementable_20.cppgccO318,571464230
Как уже отмечалось ранее, количество кода существенно уменьшается по мере развития языка: c 782 до 472 и затем до 230. Разница почти в 3,5 раза, если сравнить С++20 и С++03 (на самом деле даже больше, т.к. порядка 150‒170 символов во всех примерах — тестирующий код). Размеры объектного файла также постепенно уменьшаются. Что же со временем компиляции? Странно, но время компиляции 03 и 20 примерно равно, а вот в С++17 — в два раза больше. Давайте взглянем на код наших примеров: помимо всего прочего, в глаза бросается #include в случае C++17. Давайте реализуем declval, enable_if и void_t и проверим:

incrementable_no_tt.cpp
И давайте обновим нашу таблицу:

ФайлКомпиляцияВремя, мсРазмер объектного файла, байтКоличество символов, шт
incrementable_03.cppclangO043,021304782
incrementable_17_no_tt.cppclangO044,4981320714
incrementable_20.cppclangO043,4191304230
incrementable_03.cppclangO347,2051296782
incrementable_17_no_tt.cppclangO347,3271312714
incrementable_20.cppclangO345,7041288230
incrementable_03.cppgccO019,8851568782
incrementable_17_no_tt.cppgccO021,1631584714
incrementable_20.cppgccO017,6191480230
incrementable_03.cppgccO318,4421552782
incrementable_17_no_tt.cppgccO319,0571568714
incrementable_20.cppgccO318,5661464230
Время компиляции на 17 стандарте нормализовалось и стало практически равно времени компиляции 03 и 20, однако количество кода стало близко к самому тяжёлому, базовому варианту. Так что, если у вас есть под рукой C++20 и нужно написать какую-то простую мета-перегрузку, смело можно использовать концепты. Это читабельнее, компилируется примерно с такой же скоростью, а результат компиляции занимает меньше места.

Эксперимент №2: Ограничения для методов​

Давайте взглянем на еще одну особенность: ограничение для функции или метода (в том числе и для конструкторов и деструкторов) на примере типа OptionalLike, имеющего деструктор по умолчанию в случае, если помещаемый объект тривиален, а иначе — деструктор, выполняющий деинициализацию корректно. Код представлен ниже:

Код
Давайте взглянем на результаты:

ФайлКомпиляцияВремя, мсРазмер объектного файла, байтКоличество символов, шт
optional_like_17.cppclangO0487,621424319
optional_like_20.cppclangO0616,81816253
optional_like_17.cppclangO3490,07944319
optional_like_20.cppclangO3627,641024253
optional_like_17.cppgccO0202,291968319
optional_like_20.cppgccO0505,821968253
optional_like_17.cppgccO3205,551200319
optional_like_20.cppgccO3524,541200253
Мы видим, что новый вариант выглядит более читабельным и лаконичным (253 символа против 319 у классического), однако платим за это временем компиляции: оба компилятора как с оптимизацией, так и без показали худшее время компиляции в случае с концептами. GCC аж в 2‒2,5 раза медленнее. При этом размер объектного файла у gcc не изменяется вовсе, а в случае clang — больше для концептов. Классический компромисс: либо меньше кода, но дольше компиляция, либо больше кода, но быстрее компиляция.

Эксперимент №3: Влияние использования концептов на время компиляции​

Мы знаем, что накладывать ограничения на тип можно используя именованные наборы требований, они же концепты. Также можно указать требования непосредственно в момент объявления шаблонной сущности. Давайте посмотрим, есть ли разница с точки зрения компилятора. Компилировать будем следующие фрагменты:

Код
Сразу взглянем на результаты:

ФайлКомпиляцияВремя, мсРазмер объектного файла, байт
inline.cppclangO038,6661736
concept.cppclangO039,8681736
concept.cppclangO342,5781040
inline.cppclangO343,6101040
inline.cppgccO014,5981976
concept.cppgccO014,6401976
concept.cppgccO314,8721224
inline.cppgccO314,9511224
Как мы можем заметить, размеры получившихся объектных файлов идентичны, а показатели времени компиляции практически совпадают. Так что при выборе концепт или inline-требование можно не задумываться о производительности компилятора.

Эксперимент №4: Варианты ограничения функции​

Теперь посмотрим на варианты наложения ограничения на шаблонные параметры на примере функций. Ограничить функцию можно аж четырьмя способами:

  • Имя концепта вместо typename
  • Requires clause после template<>
  • Имя концепта рядом с auto
  • Trailing requires clause
Давайте узнаем, какой же из предложенных способов самый оптимальный с точки зрения компиляции. Компилируемый код представлен ниже:

Код
А вот и результаты:

ФайлКомпиляцияВремя, мсРазмер объектного файла, байт
function_with_auto.cppclangO040,8781760
function_after_template.cppclangO041,9471760
function_requires_clause.cppclangO042,5511760
function_instead_of_typename.cppclangO046,8931760
function_with_auto.cppclangO343,9281024
function_requires_clause.cppclangO345,1761032
function_after_template.cppclangO345,2751032
function_instead_of_typename.cppclangO350,421032
function_requires_clause.cppgccO016,5612008
function_with_auto.cppgccO016,6922008
function_after_template.cppgccO017,0322008
function_instead_of_typename.cppgccO017,8022016
function_requires_clause.cppgccO316,2331208
function_with_auto.cppgccO316,7111208
function_after_template.cppgccO317,2161208
function_instead_of_typename.cppgccO318,3151216
Как мы видим, время компиляции отличается незначительно, однако мы можем заметить следующее:

  • Вариант с использованием имени концепта вместо typename оказался самым медленным во всех случаях.
  • Варианты trailing requires clause или использование концепта рядом с auto оказались самыми быстрыми.
  • Варианты, где присутствует template<>на 5‒10% медленнее остальных.
  • Размеры объектных файлов изменяются незначительно, однако вариант с именем концепта вместо typename оказался самым объемным в случае gcc, а вариант с auto оказался наименее объемным в случае clang.

Эксперимент №5: Влияние сложности концепта на время компиляции​

Последнее, что мы рассмотрим в рамках данной статьи, и, наверное, самое интересное: влияние сложности концепта на время компиляции. Давайте возьмём и скомпилируем следующие примеры, где сложность используемого концепта (количество проверок или условий) возрастает от первого к последнему.

Код
Давайте взглянем на результат:

ФайлКомпиляцияВремя, мсКоличество символов, шт
concept_complexity_1.cppclangO037,441201
concept_complexity_2.cppclangO038,2112244
concept_complexity_3.cppclangO039,9894287
concept_complexity_1.cppclangO340,062201
concept_complexity_2.cppclangO340,6592244
concept_complexity_3.cppclangO343,3144287
concept_complexity_1.cppgccO015,352201
concept_complexity_2.cppgccO016,0772244
concept_complexity_3.cppgccO018,0914287
concept_complexity_1.cppgccO315,243201
concept_complexity_2.cppgccO317,5522244
concept_complexity_3.cppgccO318,514287
Чего и следовало ожидать, в общем случае существенное увеличение сложности концепта (обратите внимание, что концепты в примерах рекурсивные, и каждый последующий включает многократные отсылки к предыдущим) приводит к увеличению времени компиляции лишь на 5‒15%.

Заключение​

В результате вышеописанных экспериментов мы можем сделать следующие выводы:

  • Концепты позволяют создавать более читабельный код, который компилируется в меньший объектный файл, по сравнению с классическим метапрограммированием.
  • Несмотря на это, код, содержащий концепты/constraint’ы зачастую компилируется дольше, иногда довольно значительно, как это было в случае ограничения для методов.
  • Время компиляции прямо пропорционально сложности концептов/constraint'ов.

Post Scriptum​

Во-первых, к статье прилагаю ссылку на гитхаб, пройдя по которой вы можете найти скрипты для запуска тестов, а также используемые в статье фрагменты кода и повторить некоторые (а может и все) тесты локально.

Ну а во-вторых, мне бы очень хотелось увидеть, как ведут себя компиляторы с более сложными конструкциями. Если вы знаете/придумали подходящие примеры, смело пишите о них в комментариях, и я с радостью произведу замеры.


Источник статьи: https://habr.com/ru/company/orioninc/blog/562410/
 
Сверху