Сообщений 26    Оценка 240        Оценить  
Система Orphus

Производительность компиляторов С++

Качество реализаций

Авторы: Сергей Сацкий
Роман Плеханов
ЗАО Моторола

Источник: RSDN Magazine #2-2007
Опубликовано: 31.07.2007
Исправлено: 15.04.2009
Версия текста: 1.0
Введение
Аппаратные платформы, компиляторы и представление результатов тестов
Источники накладных расходов
Языковые конструкции, не приводящие к существенным накладным расходам
Накладные расходы, связанные с дополнительными уровнями абстракции
Тест Александра Степанова
Функторы и указатели на функции
Шаблоны
“Жадная” схема воплощения шаблонов
Воплощение по запросу
Итеративное воплощение
Тесты с шаблонами
Основные операции с классами
Функции-члены
Виртуальные функции – варианты для C и C++
Виртуальные и невиртуальные функции – только C++
Встраиваемые функции
Наследование и виртуальные функции
Одиночное наследование
Множественное наследование
Виртуальное наследование
RTTI
Исключения
Библиотека IOStream
Заключение
Автоматизация тестирования
Файл compilers.list
Файл projects.list
Файл optimization.info
Ключи оптимизации
Запуск
Литература

Введение

В сети можно найти достаточное количество материалов, посвященных сравнению производительности кода, сгенерированного различными компиляторами С++ на различных аппаратных платформах для определенного сорта тестовых задач (например обзор на сайте coyote gulch, [1]). Такие материалы выпускают и производители компиляторов, стараясь привлечь внимание к своим продуктам. Нет никаких сомнений в полезности подобных работ – они позволяют учитывать фактор абсолютной производительности сгенерированного кода при выборе инструмента разработки.

Однако на этапе, когда выбор компилятора уже сделан, перед разработчиком довольно часто встает вопрос о выборе в пользу того или иного подхода к реализации конкретных фрагментов программного обеспечения. Поскольку компиляторы C++ с успехом справляются и с кодом С, то всегда есть выбор – воспользоваться новыми языковыми конструкциями, предлагаемыми C++, или реализовать ту же функциональность средствами C. Другими словами возникает вопрос о накладных расходах, вносимых использованием новых языковых средств, или насколько хорошо реализован соответствующий механизм C++ в конкретном компиляторе.

Сравнений производительности кода на C++ и эквивалентного ему по функциональности кода на C, сгенерированных одним и тем же компилятором, не так много. Хорошо известен фактически только один источник – это Technical Report on C++ Performance комитета WG21 (см. [2]). В отчете приведены конкретные результаты и код, на котором производилось сравнение, однако исследуемые компиляторы остаются анонимами. Понять комитет по стандартизации можно, однако практикующим инженерам нужны более точные сведения.

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

Аппаратные платформы, компиляторы и представление результатов тестов

В таблице ниже представлены протестированные аппаратные платформы, компиляторы и операционные системы.

Аппаратная платформаКомпиляторыОперационная система
Intel, 32 битаgcc 2.95.3, gcc 3.3.4, gcc 4.1.1, Intel C++ compiler 9.1.038Linux
Intel, 64 битаgcc 2.96, gcc 3.3.4, gcc 4.1.1, Intel C++ compiler 9.1.038Linux
ARM11, 32 битаgcc 3.4.3 (кросскомпилятор)Linux
Sun UltraSPARC-II, 64 битаgcc 2.95.3, gcc 3.3.4, gcc 4.1.1Sun OS
Таблица 1. Аппаратура, компиляторы и операционные системы, участвующие в тестировании.

Один и тот же компилятор (многоплатформенный) может потенциально показать совершенно разные результаты производительности сгенерированного кода на различной аппаратуре. Результат сильно зависит от блоков кодогенерации и оптимизации для конкретной аппаратуры, поэтому один и тот же компилятор, по возможности, тестировался на разной аппаратуре.

Стоит сказать, что при разработке исходного кода тестов и окружения для их запуска прилагались усилия для сокращения требований к программному обеспечению, доступному на конкретном компьютере, и облегчению процесса добавления нового компилятора в число тестируемых. Фактически, главное требование – это наличие утилиты make. Конкретно использовались GNU-версия make, awk и bash в качестве интерпретатора команд.

Компилятор gcc серии 2 включен в список тестируемых для того, чтобы иметь возможность проследить развитие компиляторов gcc. Несмотря на то, что эта версия все еще используется, скорее это относится к языку C, чем к C++. Как только речь заходит о C++-проектах, то многие разработчики предпочитают использовать более свежие версии компилятора gcc.

Версия компилятора gcc серии 2 на платформе IA-64 отличается от версий компилятора этой же серии на других платформах. Это связано с проблемами установки gcc 2.95.3 на платформе IA-64: скрипт установки не поддерживает эту платформу. Однако поставщиком Linux предоставлялся компилятор gcc версии 2.96. Именно эта версия и использовалась для тестов на платформе IA-64.

В большинстве случаев использовалась следующая схема оценки результатов:

Таким образом, число в ячейке таблицы интерпретируется как выигрыш кода на C++, если число больше 100, и проигрыш кода на C++, если число меньше 100. В тех случаях, когда способ оценки языковых механизмов отличается от описанного выше, в соответствующих местах дается отдельное описание.

Источники накладных расходов

Можно выделить три типа накладных расходов, которые могут возникать в связи с использованием C++ вместо C:

  1. 1. Накладные расходы времени выполнения. Они делятся на следующие составляющие:
  1. 2. Накладные расходы времени компиляции. Они делятся на следующие составляющие:
  1. 3. Накладные расходы дискового пространства. Это размер исполняемого файла, сгенерированного компилятором и, возможно, размер динамически загружаемых библиотек.

Для современных систем память, как оперативная, так и дисковая, перестает быть очень дорогим ресурсом для всё более и более широкого класса задач. Поэтому самым интересным остается вопрос о производительности сгенерированного кода. Накладные расходы времени компиляции часто нивелируются тем, что используется кросс-компиляция на мощных компьютерах. По крайней мере, имеются различные пути для устранения проблем, связанных с накладными расходами времени компиляции.

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

Языковые конструкции, не приводящие к существенным накладным расходам

К таким конструкциям относятся пространства имен и явные приведения типов.

Что касается пространств имен, то, строго говоря, они могут внести накладные расходы в виде увеличения времени компиляции. Однако это увеличение пренебрежимо мало, чтобы о нем говорить серьезно.

Для приведения типов C++ предоставляет четыре новые конструкции: static_cast, const_cast, reinterpret_cast и dynamic_cast. Первые три конструкции влияют только на стадию компиляции, а dynamic_cast может приводить к накладным расходам времени выполнения. Эти накладные расходы связаны с обращением к информации о типах времени выполнения (RTTI) и будут обсуждаться далее в главе, посвященной RTTI.

Накладные расходы, связанные с дополнительными уровнями абстракции

Тест Александра Степанова

Александр Степанов, изобретатель STL, разработал набор тестов для оценки накладных расходов, связанных с введением дополнительных уровней абстракции. В тесте последовательно оценивается время выполнения семантически одинаковых действий тринадцатью различными способами. В качестве задачи выбрано вычисление суммы значений массива из 2000 величин типа double. Для введения дополнительных уровней абстракции используется обертка вокруг double-значения.

struct Double
{
    double    value;

    Double() {}
    Double( const double &  x ) : value( x ) {}
    operator double() { return value; }
};

double    data[ 2000 ];
Double    Data[ 2000 ];

Аналогичным образом вводятся обертки double_pointer и Double_pointer для указателей на double и на Double. Вычисление суммы производится следующими способами:

0. for ( size_t  i = 0; i < 2000; ++i ) result += data[ i ];
1. accumulate( data, data + 2000, 0 );
2. accumulate( Data, Data + 2000, Double( 0 ) );
3. accumulate( double_pointer( data ), double_pointer( data + 2000 ), 0 );
4. accumulate( Double_pointer( Data ), Double_pointer( Data + 2000 ), 0 );
5. Используя reverse_iterator< double *, double >
6. Используя reverse_iterator< Double *, Double >
7. Используя reverse_iterator< double_pointer, double >
8. Используя reverse_iterator< Double_pointer, Double >
9. Используя reverse_iterator< reverse_iterator< double *, double >, 
                               double >
10. Используя reverse_iterator< reverse_iterator< Double *, Double >, 
                                Double >
11. Используя reverse_iterator< reverse_iterator< double_pointer, double >,
                                double >
12. Используя reverse_iterator< reverse_iterator< Double_pointer, Double >,
                                Double >

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


Рисунок 1. Среднее геометрическое соотношений времени вычисления суммы чисел с плавающей точкой

Результирующий коэффициент характеризует качество оптимизации компилятора – чем он меньше, тем лучше компилятор справляется с накладными расходами, связанными с дополнительными уровнями абстракции. Значения больше единицы означают проигрыш в быстродействии с введением дополнительных уровней абстракции.

В таблицах ниже приведены полученные результаты.

Оптимизацияgcc 2.95gcc 3.3gcc 4.1intel 9.1
-O011.788.59.1612.12
-O21.071.141.031.06
-O3 -fomit-frame-pointer1.061.121.031.06
Таблица 2. Накладные расходы дополнительных уровней абстракции для платформы IA-32
Оптимизацияgcc 2.96gcc 3.3gcc 4.1intel 9.1
-O02.14.684.263.51
-O21.180.941.110.99
-O3 -fomit-frame-pointer1.180.941.052.04
Таблица 3. Накладные расходы дополнительных уровней абстракции для платформы IA-64
Оптимизацияgcc 2.95gcc 3.3gcc 4.1
-O05.437.797.42
-O20.531.251.12
-O3 -fomit-frame-pointer0.531.251
Таблица 4. Накладные расходы дополнительных уровней абстракции для платформы Sun
Оптимизацияgcc 3.4
-O05.32
-O20.76
-O3 –fomit-frame-pointer0.76
Таблица 5. Накладные расходы дополнительных уровней абстракции для платформы ARM

Дополнительный ключ оптимизации -fomit-frame-pointer введен для того, чтобы дать компилятору возможность как можно эффективнее использовать имеющиеся регистры процессора.

Результаты показывают, что при включении оптимизации современные компиляторы хорошо справляются с устранением накладных расходов, возникающих при введении дополнительных уровней абстракции. Неожиданные результаты продемонстрировал компилятор gcc серии 3 на платформе IA-64 и ARM. При анализе “в лоб” получается, что с ростом уровня абстракции повышается и производительность. Скорее всего, полученный результат связан с тем, как был сгенерирован код для C-версии, то есть для способа 0. Компилятор компании Intel также продемонстрировал неожиданный результат – на платформе IA-64 при усиленной оптимизации возросли накладные расходы. При детальном анализе выяснилось, что в этом случае чрезвычайно выросла производительность кода C, а производительность кода C++ осталась без изменений.

Однако общая картина остается радужной для современных компиляторов C++. Сгенерированный код практически не уступает по производительности функциональному эквиваленту C.

Функторы и указатели на функции

В некоторых случаях дополнительные уровни абстракции могут дать преимущества коду на C++ перед кодом на C. Это относится, например, к случаю использования функторов вместо указателей на функции. При вызове функции qsort ей необходимо передать указатель на функцию, предоставляющую способ сравнения элементов. Для C++ варианта можно воспользоваться стандартным алгоритмом std::sort, передавая ему различные варианты способов сравнений элементов.

В таблицах ниже приведены результаты сравнения производительности сортировки.

ОптимизацияКонтейнерСпособ сравненияgcc 2.95, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0массивуказатель на функцию187191135169
standard functor178253229184
native operator <302375317274
std::vectorуказатель на функцию1871078884
standard functor17812913084
native operator <294153147112
-O2массивуказатель на функцию220251315265
standard functor460605577706
native operator <557572611706
std::vectorуказатель на функцию220245305302
standard functor460542577662
native operator <557572577662
-O3 -fomit-frame-pointerмассивуказатель на функцию253267360265
standard functor520582673706
native operator <577582631662
std::vectorуказатель на функцию247267348302
standard functor520521631706
native operator <577550673662
Таблица 6. Производительность различных вариантов сортировки для платформы IA-32
ОптимизацияКонтейнерСпособ сравненияgcc 2.96, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0массивуказатель на функцию93786150
standard functor1461169576
native operator <158158130140
std::vectorуказатель на функцию93373428
standard functor146454338
native operator <158524850
-O2массивуказатель на функцию145144147107
standard functor187220221179
native operator <212220220178
std::vectorуказатель на функцию145139146107
standard functor188180200173
native operator <214180201176
-O3 -fomit-frame-pointerмассивуказатель на функцию150145154104
standard functor190218219176
native operator <218221219177
std::vectorуказатель на функцию150139152106
standard functor192180220173
native operator <216180219175
Таблица 7. Производительность различных вариантов сортировки для платформы IA-64
ОптимизацияКонтейнерСпособ сравненияgcc 2.95, %gcc 3.3, %gcc 4.1, %
-O0массивуказатель на функцию748168
standard functor11510494
native operator <160187179
std::vectorуказатель на функцию734638
standard functor1154945
native operator <1606355
-O2Массивуказатель на функцию696375
standard functor232268402
native operator <291341402
std::vectorуказатель на функцию686372
standard functor232252353
native operator <281309368
-O3 -fomit-frame-pointerмассивуказатель на функцию726381
standard functor309273520
native operator <334363505
std::vectorуказатель на функцию716377
standard functor321269491
native operator <334327505
Таблица 8. Производительность различных вариантов сортировки для платформы Sun.
ОптимизацияКонтейнерСпособ сравненияgcc 3.4, %
-O0массивуказатель на функцию186
standard functor180
native operator <293
std::vectorуказатель на функцию72
standard functor71
native operator <86
-O2Массивуказатель на функцию234
standard functor371
native operator <396
std::vectorуказатель на функцию236
standard functor359
native operator <371
-O3 -fomit-frame-pointerмассивуказатель на функцию235
standard functor369
native operator <388
std::vectorуказатель на функцию235
standard functor364
native operator <369
Таблица 9. Производительность различных вариантов сортировки для платформы ARM

Анализ результатов показывает существенный выигрыш в скорости кода C++, который может достигать 600% в отдельных случаях. При этом выигрыш на платформе IA-32 гораздо существеннее выигрыша на других платформах. Скорее всего, это свидетельствует о недостаточной “зрелости” компиляторов для других платформ.

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

Стоит отметить, что сортировка с использованием встроенного оператора сравнения (::operator < (…)) почти всегда выполнялась быстрее, чем другие варианты.

Шаблоны

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

Во время компиляции, напротив, шаблоны могут внести существенные накладные расходы, связанные со временем компиляции. Кроме того, могут возникнуть накладные расходы дискового пространства, связанные с эффектом «разбухания» кода.

Существуют три основные схемы реализации механизма воплощения шаблонов компиляторами C++:

Работа механизмов воплощения шаблонов может сильно зависеть от схемы сборки приложения или библиотеки. Предположим, что сборка основана на использовании двух классических компонентов – компилятора и компоновщика. Компилятор преобразует исходный код в объектные файлы, которые содержат машинный код и перекрестные ссылки на другие объектные файлы и библиотеки. Компоновщик создает исполняемые программы или библиотеки, соединяя объектные файлы в одно целое, разрешая содержащиеся в них перекрестные ссылки. Компиляторы C и C++ обрабатывают каждую единицу трансляции независимо. В случае шаблонов, при “лобовом” подходе, для каждой единицы трансляции будут воплощены шаблоны невстраиваемых функций. Таким образом, есть шанс, что в нескольких объектных файлах окажутся тела функций с одинаковыми именами. Этап компоновки в таком случае, скорее всего, закончится неуспешно.

Рассмотрим подробнее, как каждая из упомянутых выше схем реализации механизма воплощения решает эту проблему, и какие накладные расходы при этом возникают.

“Жадная” схема воплощения шаблонов

Схема “жадного” воплощения (от английского instantiate - прим.ред.) допускает создание дубликатов в нескольких объектных файлах, однако для таких дубликатов вводятся специальные пометки (например, подлежащие компоновке воплощенные шаблоны). Когда компоновщик обнаруживает помеченные дубликаты, он оставляет только один, отбрасывая остальные. Описанная схема обладает, по крайней мере, следующими недостатками:

Имеются и достоинства:

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

Воплощение по запросу

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

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

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

Итеративное воплощение

Существуют различные способы реализации схемы итеративного воплощения шаблонов. Их общей особенностью является использование предварительного компоновщика. Один из вариантов итеративного воплощения реализован в компиляторе компании Comeau Computing. Последовательность выполняемых действий такова:

При первой компиляции воплощение шаблонов не выполняется, однако объектные файлы будут содержать отметки о том, какие шаблоны могли бы быть воплощены. Для каждой единицы трансляции, использующей шаблоны, создается файл запросов воплощения “.ii”.

Этап компоновки перехватывается предварительным компоновщиком. Он просматривает объектные файлы и принимает во внимание ссылки на воплощенные шаблоны, а также ссылки на шаблоны, которые потенциально могли бы быть воплощены.

Если предварительный компоновщик встречает ссылку на еще невоплощенный ни в одном объектном файле шаблон, он ищет объектный файл, который мог бы создать воплощение данной специализации. Когда подходящий файл найден, предварительный компоновщик делает запись в соответствующий “.ii” файл о необходимости создания недостающей специализации.

Для тех единиц трансляции, чьи “.ii” файлы были изменены, предварительный компоновщик снова вызывает компилятор.

Созданный в результате повторного запуска компилятора объектный файл будет расширен результатами компиляции специализаций шаблонов, указанных в “.ii” файле.

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

Наконец, вызывается традиционный компоновщик для полученных объектных файлов.

Одна из вариаций заключается в том, что компилятор хранит отметки о возможных воплощениях не в объектных файлах, а в файлах запросов воплощения, храня в них также информацию о структуре объектного файла.

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

Тесты с шаблонами

В тестах с шаблонами собиралась информация о времени компиляции и размерах исполняемого файла с информацией о символах и без таковой. Удаление информации о символах производилось с помощью утилиты strip.

Тестированию подвергались два варианта исходных текстов . Первый вариант создавал 40 экземпляров контейнерных классов std::list, а элементом контейнеров служили указатели на различные типы. Второй вариант создавал 40 экземпляров классов std::list, а элементом контейнеров служил указатель на один и тот же тип. Результаты тестов представлены в таблицах ниже.

ОптимизацияВариант исходного текстаИзмеряемая величинаgcc 2.95gcc 3.3gcc 4.1intel 9.1
-O240 разных шаблоновВремя компиляции, сек21220311
Размер до strip, килобайт5058710145
Размер после strip, килобайт222836109
40 одинаковых шаблоновВремя компиляции, сек2652216
Размер до strip, килобайт49880694
Размер после strip, килобайт21778485
-O3 -fomit-frame-pointers40 разных шаблоновВремя компиляции, сек37120311
Размер до strip, килобайт602878145
Размер после strip, килобайт320836109
40 одинаковых шаблоновВремя компиляции, сек5182226
Размер до strip, килобайт59480894
Размер после strip, килобайт31478685
-Os40 разных шаблоновВремя компиляции, сек22724410
Размер до strip, килобайт5058829148
Размер после strip, килобайт2228310105
40 одинаковых шаблоновВремя компиляции, сек2942716
Размер до strip, килобайт49881793
Размер после strip, килобайт21779581
Таблица 10. Время компиляции и размеры файлов для теста шаблонов для платформы IA-32
ОптимизацияВариант исходного текстаИзмеряемая величинаgcc 2.96gcc 3.3gcc 4.1intel 9.1
-O240 разных шаблоновВремя компиляции, сек402927
Размер до strip, килобайт37511720308
Размер после strip, килобайт36811215212
40 одинаковых шаблоновВремя компиляции, сек342713
Размер до strip, килобайт36010611124
Размер после strip, килобайт3561048116
-O3 -fomit-frame-pointers40 разных шаблоновВремя компиляции, сек402927
Размер до strip, килобайт37511715308
Размер после strip, килобайт36811212212
40 одинаковых шаблоновВремя компиляции, сек352713
Размер до strip, килобайт36010715124
Размер после strip, килобайт35610412116
-Os40 разных шаблоновВремя компиляции, сек333237
Размер до strip, килобайт37511964320
Размер после strip, килобайт36811343216
40 одинаковых шаблоновВремя компиляции, сек563113
Размер до strip, килобайт36010813128
Размер после strip, килобайт35610510116
Таблица 11. Время компиляции и размеры файлов для теста шаблонов для платформы IA-64
ОптимизацияВариант исходного текстаИзмеряемая величинаgcc 2.95gcc 3.3gcc 4.1
-O240 разных шаблоновВремя компиляции, сек164988
Размер до strip, килобайт7987719
Размер после strip, килобайт2167113
40 одинаковых шаблоновВремя компиляции, сек160902
Размер до strip, килобайт785648
Размер после strip, килобайт206615
-O3 -fomit-frame-pointers40 разных шаблоновВремя компиляции, сек1659910
Размер до strip, килобайт7977710
Размер после strip, килобайт216717
40 одинаковых шаблоновВремя компиляции, сек158925
Размер до strip, килобайт7846410
Размер после strip, килобайт205617
-Os40 разных шаблоновВремя компиляции, сек1801089
Размер до strip, килобайт7987862
Размер после strip, килобайт2177244
40 одинаковых шаблоновВремя компиляции, сек173992
Размер до strip, килобайт785659
Размер после strip, килобайт206626
Таблица 12. Время компиляции и размеры файлов для теста шаблонов для платформы Sun
ОптимизацияВариант исходного текстаИзмеряемая величинаgcc 3.4
-O240 разных шаблоновРазмер до strip, килобайт20
Размер после strip, килобайт8
40 одинаковых шаблоновРазмер до strip, килобайт24
Размер после strip, килобайт8
-O3 -fomit-frame-pointers40 разных шаблоновРазмер до strip, килобайт20
Размер после strip, килобайт18
40 одинаковых шаблоновРазмер до strip, килобайт24
Размер после strip, килобайт18
-Os40 разных шаблоновРазмер до strip, килобайт29
Размер после strip, килобайт18
40 одинаковых шаблоновРазмер до strip, килобайт24
Размер после strip, килобайт18
Таблица 13. Время компиляции и размеры файлов для теста шаблонов для платформы ARM

Интересными результатами здесь является подтверждение факта, что компиляторы сделали значительный шаг вперед в плане уменьшения времени компиляции и уменьшения размера сгенерированного кода. В некоторых случаях время компиляции для gcc сократилось более чем в 100 раз при переходе с серии 2 к серии 4.

Для платформы ARM время компиляции не приводится. Поскольку использовался кросс-компилятор, время полностью зависело от производительности хост-системы (IA-32). Как таковые результаты только для одного компилятора для ARM не представляют существенного интереса, однако таблица приведена с надеждой на добавление новых колонок в будущем.

Основные операции с классами

Функции-члены

Вызов функции-члена приблизительно эквивалентен вызову функции с одним дополнительным параметром – указателем на объект. Рассмотрим три варианта, описанные в таблице ниже:

ОписаниеВариант C++Вариант C
Нотация “стрелка”x->g( i );g( ps, i );
Нотация “точка”x.g( i );g( &s, i );
Статическая функция член класса и свободная функцияX::f( i );f( i );
Таблица 14. Варианты вызова функций-членов.

В тестах сравнивались вызовы функций с целочисленным параметром, который в таблице показан как i. Параметр ps в таблице – указатель, а s – объект.

В таблицах ниже приведены результаты сравнения производительности вызовов C++ и C.

ОптимизацияВариант тестаgcc 2.95, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0Нотация “стрелка”102989998
Нотация “точка”1019896101
Статическая функция-член класса и свободная функция105100100100
-O2Нотация “стрелка”9587102100
Нотация “точка”11090100104
Статическая функция-член класса и свободная функция101100100153
-O3 –fomit-frame-pointerНотация “стрелка”1069510490
Нотация “точка”111100104104
Статическая функция-член класса и свободная функция10010195160
Таблица 15. Производительность различных вариантов вызовов функций для платформы IA-32.
ОптимизацияВариант тестаgcc 2.96, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0Нотация “стрелка”819595100
Нотация “точка”81959599
Статическая функция-член класса и свободная функция96100100100
-O2Нотация “стрелка”3827011786
Нотация “точка”382438385
Статическая функция-член класса и свободная функция6310010099
-O3 –fomit-frame-pointerНотация “стрелка”378310085
Нотация “точка”368320785
Статическая функция-член класса и свободная функция6310033100
Таблица 16. Производительность различных вариантов вызовов функций для платформы IA-64.
ОптимизацияВариант тестаgcc 2.95, %gcc 3.3, %gcc 4.1, %
-O0Нотация “стрелка”11411399
Нотация “точка”11485100
Статическая функция-член класса и свободная функция1009999
-O2Нотация “стрелка”10010290
Нотация “точка”1009987
Статическая функция-член класса и свободная функция928799
-O3 –fomit-frame-pointerНотация “стрелка”99100100
Нотация “точка”9989100
Статическая функция-член класса и свободная функция9991100
Таблица 17. Производительность различных вариантов вызовов функций для платформы Sun.
ОптимизацияВариант тестаgcc 3.4, %
-O0Нотация “стрелка”100
Нотация “точка”99
Статическая функция-член класса и свободная функция100
-O2Нотация “стрелка”118
Нотация “точка”112
Статическая функция-член класса и свободная функция89
-O3 –fomit-frame-pointerНотация “стрелка”100
Нотация “точка”151
Статическая функция-член класса и свободная функция101
Таблица 18. Производительность различных вариантов вызовов функций для платформы ARM.

Производительность C++ на платформах IA-32, Sun и ARM в большинстве случаев не отличается от производительности C больше чем на 10%. На платформе IA-64 результаты менее ровные. Производительность C++ сильно зависит от конкретного случая и может варьироваться от подавляющего превосходства C++ (gcc серии 4 с максимальной оптимизацией для нотации “точка” – 207%), до сильного проигрыша (gcc серии 4 с максимальной оптимизацией для статических функций членов– 33%).

Виртуальные функции – варианты для C и C++

Виртуальные функции, также как и не виртуальные, могут вызываться с использованием нотаций “точка” и “стрелка”. Поскольку указатели на виртуальные функции хранятся в отдельной таблице, то вызов виртуальной функции будет приблизительно эквивалентен вызову функции с одним дополнительным параметром по указателю, хранящемуся в массиве. В таблице ниже представлены варианты вызовов для C++ и для C.

ОписаниеВариант C++Вариант C
Нотация “стрелка”x->f( i );(p[1])(ps,i);
Нотация “точка”x.f( i );(p[1])(&s,i);
Таблица 19. Варианты вызова виртуальных функций-членов.

Здесь i – целочисленный параметр, p – массив указателей на функции, ps – указатель на объект, а s – объект.

В таблицах ниже приведены результаты сравнения производительности вызовов C++ и C.

ОптимизацияНотацияgcc 2.95, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0Нотация “стрелка”928711491
Нотация “точка”104103101105
-O2Нотация “стрелка”89929097
Нотация “точка”110106110702
-O3 -fomit-frame-pointerНотация “стрелка”97939197
Нотация “точка”122106500702
Таблица 20. Производительность различных вариантов вызовов виртуальных функций для платформы IA-32.
ОптимизацияНотацияgcc 2.96, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0Нотация “стрелка”819595100
Нотация “точка”81959599
-O2Нотация “стрелка”96100100100
Нотация “точка”3827011786
-O3 -fomit-frame-pointerНотация “стрелка”382438385
Нотация “точка”6310010099
Таблица 21. Производительность различных вариантов вызовов виртуальных функций для платформы IA-64.
ОптимизацияНотацияgcc 2.95, %gcc 3.3, %gcc 4.1, %
-O0Нотация “стрелка”949495
Нотация “точка”158112152
-O2Нотация “стрелка”779195
Нотация “точка”224207206
-O3 -fomit-frame-pointerНотация “стрелка”818593
Нотация “точка”2052251234
Таблица 22. Производительность различных вариантов вызовов виртуальных функций для платформы Sun.
ОптимизацияНотацияgcc 3.4, %
-O0Нотация “стрелка”90
Нотация “точка”125
-O2Нотация “стрелка”96
Нотация “точка”141
-O3 -fomit-frame-pointerНотация “стрелка”96
Нотация “точка”498
Таблица 23. Производительность различных вариантов вызовов виртуальных функций для платформы ARM.

Можно заметить, что производительность вызовов с использованием нотации “точка” для С++ почти всегда выигрывает у варианта для С. Иногда выигрыш достигает существенных величин – пяти-семикратного выигрыша С++. Вероятно, это связано с особенностями работы оптимизатора. Для С++, в случае нотации "точка", оптимизатор способен провести девиртуализацию, в то время как для C подобных попыток не делается.

Для нотации “стрелка” наблюдается небольшой выигрыш у C-варианта. Для платформы IA-32 без оптимизации вариант C++ у компилятора gcc серии 4 оказался производительнее. А компилятор компании Intel на платформе IA-64 без оптимизации показал провал производительности варианта C++.

Виртуальные и невиртуальные функции – только C++

Затраты на вызов виртуальной и невиртуальной функции для C++ могут отличаться. В таблицах ниже приведены результаты сравнения производительности вызовов виртуальных и невиртуальных функций. В ячейках таблиц указан процент производительности вызовов виртуальных функций по отношению к вызовам невиртуальных функций. Соответственно, число больше 100 означает, что вызов виртуальной функции, в среднем, обошелся дешевле вызова невиртуальной функции.

ОптимизацияНотацияgcc 2.95, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0Нотация “стрелка”808711292
Нотация “точка”9999100101
-O2Нотация “стрелка”9085906
Нотация “точка”98100100100
-O3 -fomit-frame-pointerНотация “стрелка”8183166
Нотация “точка”10010095100
Таблица 24. Производительность различных вариантов вызовов виртуальных и обычных функций для платформы IA-32.
ОптимизацияНотацияgcc 2.96, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0Нотация “стрелка”77797742
Нотация “точка”95100100100
-O2Нотация “стрелка”150705977
Нотация “точка”25810085526
-O3 -fomit-frame-pointerНотация “стрелка”158601377
Нотация “точка”273100100699
Таблица 25. Производительность различных вариантов вызовов виртуальных и обычных функций для платформы IA-64.
ОптимизацияНотацияgcc 2.95, %gcc 3.3, %gcc 4.1, %
-O0Нотация “стрелка”527064
Нотация “точка”9910097
-O2Нотация “стрелка”364146
Нотация “точка”10096114
-O3 -fomit-frame-pointerНотация “стрелка”38427
Нотация “точка”10011199
Таблица 26. Производительность различных вариантов вызовов виртуальных и обычных функций для платформы Sun.
ОптимизацияНотацияgcc 3.4, %
-O0Нотация “стрелка”72
Нотация “точка”99
-O2Нотация “стрелка”64
Нотация “точка”93
-O3 -fomit-frame-pointerНотация “стрелка”19
Нотация “точка”119
Таблица 27. Производительность различных вариантов вызовов виртуальных и обычных функций для платформы ARM.

Тот факт, что для нотации “точка” производительность вызовов виртуальных и невиртуальных функций оказалась приблизительно одинакова, можно объяснить тем, что компилятор смог произвести девиртуализацию вызовов функций.

В случае нотации “стрелка” виртуальные функции проигрывают невиртуальным в подавляющем большинстве случаев. Иногда проигрыш очень существенен – на платформе IA-32 компилятор компании Intel проиграл в 16 раз, а компилятор gcc серии 4 проиграл в 6 раз.

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

Встраиваемые функции

C++ предлагает альтернативу макросам языка C – встраиваемые функции. В таблицах ниже приведены результаты сравнения производительности этих механизмов для двух вариантов: нотаций “точка” и “стрелка”.

ОптимизацияНотацияgcc 2.95, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0Нотация “стрелка”64544947
Нотация “точка”31493635
-O2Нотация “стрелка”10012310095
Нотация “точка”9798100104
-O3 -fomit-frame-pointerНотация “стрелка”9782100100
Нотация “точка”10298102102
Таблица 28. Отношение производительности встраиваемых функций и макросов для платформы IA-32.
ОптимизацияНотацияgcc 2.96, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0Нотация “стрелка”108647468
Нотация “точка”95525858
-O2Нотация “стрелка”101339933
Нотация “точка”446100300299
-O3 -fomit-frame-pointerНотация “стрелка”30199300100
Нотация “точка”44710033200
Таблица 29. Отношение производительности встраиваемых функций и макросов для платформы IA-64.
ОптимизацияНотацияgcc 2.95, %gcc 3.3, %gcc 4.1, %
-O0Нотация “стрелка”636458
Нотация “точка”844447
-O2Нотация “стрелка”9910099
Нотация “точка”9910099
-O3 –fomit-frame-pointerНотация “стрелка”10099100
Нотация “точка”100100100
Таблица 30. Отношение производительности встраиваемых функций и макросов для платформы Sun.
ОптимизацияНотацияgcc 3.4, %
-O0Нотация “стрелка”48
Нотация “точка”38
-O2Нотация “стрелка”120
Нотация “точка”100
-O3 -fomit-frame-pointerНотация “стрелка”101
Нотация “точка”83
Таблица 31. Отношение производительности встраиваемых функций и макросов для платформы ARM.

Без включения оптимизации компиляторы, как правило, не пытаются встроить вызовы функций. Первые две строчки в каждой из таблиц подтверждают это предположение.

Результаты при включении оптимизации очень сильно различаются для различных платформ и случаев. Наиболее стабильные результаты показывает компилятор gcc серии 4 на платформе IA-32 – производительность встраиваемых функций и макросов оказалась одинаковой. Компилятор компании Intel демонстрирует провал производительности встраиваемых функций для нотации “стрелка” на платформе IA-32.

На платформе IA-64 возможен как выигрыш в производительности (например, компилятор компании Intel для нотации “точка”), так и существенный проигрыш (например, gcc серии 4 для нотации “точка” и оптимизации -O2).

Компилятор gcc, запущенный с ключом -O0, не производит встраивание функций, поэтому макросы оказываются эффективнее. Включение встраивания функций приводит к тому, что производительность макросов и функций становится примерно одинаковой.

Наследование и виртуальные функции

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

Рассмотрим более подробно, что происходит в различных вариантах наследования для типичной реализации.

Одиночное наследование

Предположим, что следующий тип используется в качестве базового (структура используется только для того, чтобы избежать использования ключевого слова public и тем самым сэкономить одну строку):

struct Base
{
  Data          d1;

  virtual void  f( void );
  void          g( void );
};

Экземпляры типа Base будут располагаться в памяти так, как показано на рисунке 2.


Рисунок 2. Размещение объекта с виртуальной функцией.

В таблице виртуальных функций для типа Base будет один указатель на виртуальную функцию, а данные будут расширены указателем на таблицу виртуальных функций. Какие именно элементы хранятся в таблице виртуальных функций несущественно. Это могут быть указатели, смещения для корректировки указателя this или что-нибудь еще.

Теперь предположим, что имеется тип Derived, наследующий от Base:

struct Derived : public Base
{
  Data          d2;

  virtual void  f( void );
  virtual void  h( void );
};

Размещение объектов типа Derived в памяти представлено на рисунке 3.


Рисунок 3. Размещение производного объекта с виртуальными функциями.

Данные базового типа будут располагаться в памяти сначала, за ними будут следовать данные производного класса. Таблица виртуальных функций будет расширена еще одним указателем &Derived::h, а указатель &Base::f будет заменен на &Derived::f.

Стоит заметить, что в случае размещения в памяти экземпляра типа Derived (т.е. одиночного наследования), адреса Base- и Derived-частей будут совпадать. Еще одной особенностью является потенциальная возможность хранить один экземпляр таблицы виртуальных функций для всех объектов типа. Это позволяет снизить накладные расходы оперативной памяти времени выполнения и, возможно, занимаемого дискового пространства.

Множественное наследование

Предположим, что имеются два базовых типа Base1 и Base2:

struct Base1
{
  Data          d1;

  virtual void  f( void );
};
struct Base2
{
  Data          d2;

  virtual void  f( void );
  virtual void  g( void );
};

Тип DerivedMultilpe наследует от Base1 и Base2:

struct DerivedMultiple : public Base1, public Base2
{
  Data          d3;

  virtual void  f( void );
  virtual void  g( void );
  virtual void  h( void );
};

Размещение объектов типа Base1 и Base2 аналогично размещению объектов типа Base, показанному на рисунке 2. Интерес представляет размещение объектов типа DerivedMultiple:


Рисунок 4. Размещения объекта с виртуальными функциями в случае множественного наследования.

На рисунке s означает размер, занимаемый экземпляром типа Base1 в памяти.

В памяти сначала будут располагаться данные базового типа Base1, затем данные базового типа Base2, и только после них – данные DerivedMultiple. Важным моментом здесь является то, что у объекта DerivedMultiple может быть два адреса, на рисунке адреса помечены как a1 и a2. Эти адреса появляются, если разработчик пишет код, подобный следующему:

DerivedMultiple *  Object( new DerivedMultiple );  // Соответствует a1
Base1 *            base1( Object );                // Также соответствует a1
Base2 *            base2( Object );                // Соответствует a2

Однако при вызове виртуальной функции f ей необходимо передать правильный указатель this, то есть указатель на реально созданный объект – в нашем случае a1. Для указателя base2 потребуются дополнительные действия: необходимо указатель a2 скорректировать на размер, занимаемый base1, то есть s. В связи с этим в таблицах виртуальных функций на рисунке появился еще один информационный элемент – величина, на которую нужно изменять указатель this при вызове виртуальных функций.

Аналогичная ситуация возникает и при таком способе использования приведенной иерархии типов:

Base2 *  base2a( new Base2 );
Base2 *  base2b( new DerivedMultiple );

base2a->f();
base2b->f();

Здесь указатель Base2 * может указывать либо на объект Base2, либо на часть объекта DerivedMultiple. При вызове виртуальной функции f в первом случае будет вызвана Base2::f, а во втором – DerivedMultiple::f. Так как base2b указывает на подобъект типа DerivedMultiple, то для вызова base2b->f() указатель base2b надо подкорректировать, чтобы он определял объект типа DerivedMultiple. Величина корректировки составит s.

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

Стоит отметить, что довольно часто вызов виртуальной функции производится в контексте, когда у компилятора есть все сведения о типах, необходимые для замены виртуального вызова обычным. Такая оптимизация называется девиртуализацией и позволяет перейти от косвенного вызова через таблицу виртуальных функций к прямому вызову.

Что касается техники, используемой компиляторами для реализации виртуальных функций, то существует, по крайней мере, два подхода. Первый связан с хранением дельты для корректировки указателя this, как показано на рисунках выше. Второй способ подразумевает генерацию небольших фрагментов кода («thunk»), которые корректируют this. В случае отсутствия необходимости в корректировки соответствующий фрагмент получается пустым, чем достигается оптимизация вызовов виртуальных функций.

Результаты тестирования

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

На диаграмме ниже приведены две иерархии типов, использовавшиеся в тестах. Для случая множественного наследования ветвь, относящаяся к базовому классу Base1, в дальнейшем будет именоваться, для краткости, первой ветвью наследования. А ветвь, относящаяся к базовому классу Base2 – второй ветвью наследования.


Рисунок 5. Использованные вызовы.

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

В таблице ниже сравнивается производительность вызовов функций в случае множественного и одиночного наследования. Соответственно число больше 100 означает, что вызов функции при множественном наследовании, в среднем, обошелся дешевле вызова функции при одиночном наследовании.

ОптимизацияТип функцииВетвь наследованияgcc 2.95, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0НевиртуальнаяBase110510394101
Base298999496
ВиртуальнаяBase110010010099
Base288829061
-O2НевиртуальнаяBase1102102100100
Base2102103104100
ВиртуальнаяBase1100989999
Base278979499
-O3 -fomit-frame-pointerНевиртуальнаяBase110299102100
Base2102118102100
ВиртуальнаяBase1100999998
Base286698399
Таблица 32. Производительность различных вариантов вызовов функций. Множественное и одиночное наследование для платформы IA-32.
ОптимизацияТип функцииВетвь наследованияgcc 2.96, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0НевиртуальнаяBase110310099100
Base295929596
ВиртуальнаяBase11001009999
Base2124939662
-O2НевиртуальнаяBase13699116100
Base2379911699
ВиртуальнаяBase1991009999
Base285919099
-O3 -fomit-frame-pointerНевиртуальнаяBase129810010099
Base21003003399
ВиртуальнаяBase1100999999
Base2869090100
Таблица 33. Производительность различных вариантов вызовов функций. Множественное и одиночное наследование для платформы IA-64.
ОптимизацияТип функцииВетвь наследованияgcc 2.95, %gcc 3.3, %gcc 4.1, %
-O0НевиртуальнаяBase1100113100
Base21089497
ВиртуальнаяBase18898100
Base2908284
-O2НевиртуальнаяBase19910099
Base29910491
ВиртуальнаяBase11089499
Base21076497
-O3 -fomit-frame-pointerНевиртуальнаяBase1100100100
Base2100100100
ВиртуальнаяBase197100100
Base21017099
Таблица 34. Производительность различных вариантов вызовов функций. Множественное и одиночное наследование для платформы Sun.
ОптимизацияТип функцииВетвь наследованияgcc 3.4, %
-O0НевиртуальнаяBase1100
Base292
ВиртуальнаяBase1100
Base285
-O2НевиртуальнаяBase177
Base2100
ВиртуальнаяBase199
Base279
-O3 -fomit-frame-pointerНевиртуальнаяBase1100
Base2100
ВиртуальнаяBase199
Base279
Таблица 35. Производительность различных вариантов вызовов функций. Множественное и одиночное наследование для платформы ARM.

Можно заметить, что у современных компиляторов порядок наследования практически не влияет на производительность вызовов невиртуальных функций. Другая ситуация с виртуальными функциями. При включенной оптимизации для компилятора компании Intel стоимость вызова виртуальной функции не зависела от порядка наследования и оказалась практически равной стоимости вызова виртуальной функции при одиночном наследовании.

Для компиляторов gcc серий 3 и 4 при включенной оптимизации есть небольшая разница в производительности вызовов виртуальных функций для разных ветвей наследования. Потеря производительности вызовов виртуальных функций по второй ветви наследования в сравнении с вызовами виртуальных функций в случае одиночного наследования составляет от 10% до 30%. Такие потери практически отсутствуют для вызовов по первой ветви наследования.

Виртуальное наследование

В случае виртуального наследования структуры данных становятся еще более сложными. Рассмотрим пример такой иерархии типов:


Рисунок 6. Иерархия типов с виртуальным наследованием

Здесь Mediator1 и Mediator2 виртуально наследуют от TopBase. Предположим, что соответствующие типы определены так:

struct TopBase
{
    Data          d1;

    virtual void  f( void );
};

struct Mediator1 : virtual public TopBase
{
    Data          d2;

    virtual void  f( void );
    virtual void  g( void );
};
struct Mediator2 : virtual public TopBase
{
    Data          d3;

    virtual void  f( void );
    virtual void  h( void );
};

struct DerivedVirtual : public Mediator1, public Mediator2
{
    Data          d4;

    virtual void  f( void );
    virtual void  g( void );
    virtual void  h( void );
};

Объекты типа TopBase будут располагаться в памяти способом, аналогичным представленному на рисунке 2. Расположение в памяти объектов типов Mediator1 и Mediator2 уже будет отличаться. На рисунке ниже приведен способ размещения объектов типа Mediator1 для типичной реализации. Объекты типа Mediator2 будут располагаться аналогичным образом.


Рисунок 7. Размещение в памяти объекта с виртуальным базовым классом.

Здесь данные виртуального базового класса располагаются после всех остальных данных. Это делается для унификации действий, выполняемых во время выполнения, независимо от того, объекты каких типов были созданы (в примере Mediator1, Mediator2 или DerivedVirtual). Таблица виртуальных функций для Mediator1 расширяется еще одним элементом – указателем на реальное расположение данных виртуального базового класса. При этом доступ к данным виртуального базового класса будет осуществляться не напрямую, а через дополнительный указатель в таблице виртуальных функций. Это приводит к накладным расходам времени выполнения.

На рисунке 8 показано как будут размещаться в памяти объекты типа DerivedVirtual.


Рисунок 8. Размещение в памяти объекта с виртуальным базовым классом в случае множественного наследования.

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

Для описанной выше реализации, как в случае создания объектов Mediator1, Mediator2 или DerivedVirtual, доступ к данным виртуального базового типа будет осуществляться одинаково – через дополнительный указатель в таблице виртуальных функций. Это справедливо и для случая, когда создан объект типа DerivedVirtual и указатель на созданный объект преобразован к указателю на Mediator1 или Mediator2.

Некоторые реализации хранят указатель на начало данных виртуального базового типа не в таблице виртуальных функций, а как дополнительный член данных типа.

Результаты тестирования

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

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

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


Рисунок 9. Вызов виртуальной функции, вариант 2а


Рисунок 10. Вызов виртуальной функции, вариант 2б

В ячейках таблиц указан процент производительности различных вариантов вызовов функций при виртуальном наследовании, по отношению к производительности вызовов функций при обычном наследовании. Соответственно число больше 100 означает, что вызов функции при виртуальном наследовании, в среднем, обошелся дешевле вызова функции при обычном наследовании.

ОптимизацияВариант вызова функцииgcc 2.95, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0Вариант 191687479
Вариант 2а72646154
Вариант 2б69545750
-O2Вариант 183576274
Вариант 2а75545762
Вариант 2б60485458
-O3 -fomit-frame-pointerВариант 128482774
Вариант 2а76474862
Вариант 2б57414258
Таблица 36. Производительность различных вариантов вызовов функций. Виртуальное и обычное наследование для платформы IA-32.
ОптимизацияВариант вызова функцииgcc 2.96, %gcc 3.3, %gcc 4.1, %intel 9.1, %
-O0Вариант 195797745
Вариант 2а123757635
Вариант 2б120616625
-O2Вариант 190666677
Вариант 2а911386060
Вариант 2б1391165050
-O3 -fomit-frame-pointerВариант 1331610077
Вариант 2а911386060
Вариант 2б1391165049
Таблица 37. Производительность различных вариантов вызовов функций. Виртуальное и обычное наследование для платформы IA-64.
ОптимизацияВариант вызова функцииgcc 2.95, %gcc 3.3, %gcc 4.1, %
-O0Вариант 1949685
Вариант 2а1016173
Вариант 2б975865
-O2Вариант 1949584
Вариант 2а936281
Вариант 2б915771
-O3 -fomit-frame-pointerВариант 1181618
Вариант 2а926186
Вариант 2б835670
Таблица 38. Производительность различных вариантов вызовов функций. Виртуальное и обычное наследование для платформы Sun.
ОптимизацияВариант вызова функцииgcc 3.4, %
-O0Вариант 176
Вариант 2а58
Вариант 2б50
-O2Вариант 171
Вариант 2а67
Вариант 2б54
-O3 -fomit-frame-pointerВариант 122
Вариант 2а57
Вариант 2б48
Таблица 39. Производительность различных вариантов вызовов функций. Виртуальное и обычное наследование для платформы ARM.

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

RTTI

Наиболее интересными случаями применения информации о типах времени выполнения являются попытки преобразования, связанные с анализом иерархии типов. Такой анализ выполняется, например, при использовании преобразования dynamic_cast. Язык C не поддерживает явного создания иерархии типов, поэтому сравнить функционально эквивалентный C- и C++-код представляется затруднительным. Поэтому ограничимся теоретическим рассмотрением возможной реализации механизма поддержки информации о типах времени выполнения и анализом возникающих при этом накладных расходов.

Предположим, что имеется иерархия типов, представленная на рисунке ниже (стоит заметить, что тип E должен быть полиморфным).


Рисунок 11. Иерархия типов для разбора механизма RTTI..

Теперь предположим, что имеется такой фрагмент кода на C++:

E *       pE( new E );
B *       pB( pE );

D *       pD( dynamic_cast< D * >( pB ) );

Очевидно, что преобразование в последней строке должно завершиться успешно. Однако в качестве аргумента для преобразования передается указатель на объект типа B, который не является прямым базовым типом для D. Для корректного преобразования необходимо спуститься по иерархии типов до типа E и затем выполнить преобразование. Подобный обход иерархии типов может быть выполнен с помощью реализации поддержки информации о типах времени выполнения, представленной на рисунке ниже.


Рисунок 12. Возможная реализация механизма RTTI

Таблица виртуальных функций расширяется еще одним указателем, который позволяет получить информацию о типе. Сама информация обо всех типах хранится в памяти в отдельной таблице. При наличии указателя pB сначала осуществляется поиск начала реально созданного объекта, а затем находится таблица со списком информации о типах всех предков объекта. Далее производится последовательное сравнение type_info для типа-источника со списком типов из таблицы. Если на каком-то шаге type_info совпали, то преобразование возможно.

Таким образом, накладными расходами будут являться затраты памяти на хранение дополнительных указателей и хранение таблицы RTTI, а также накладные расходы процессорного времени на поиск таблицы и последовательные сравнения. Обычно сравнения не требуют дорогостоящих сравнений строк. Однако существуют реализации компиляторов C++, которые полагаются на строковые сравнения.

Исключения

C++ предлагает механизм исключений в качестве способа обработки ошибок. Традиционные альтернативы языка С это:

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

В случае табличного подхода на этапе компиляции создаются таблицы, в которых в соответствие диапазонам значений счетчика команд ставятся действия, которые необходимо выполнить в случае возникновения исключительной ситуации. Это может быть передача управления соответствующему catch-блоку, вызов деструкторов локальных объектов, раскрутка стека и т. п. Основным накладным расходом времени выполнения при таком подходе будет оперативная память, в которой будут храниться подготовленные таблицы.

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

Наиболее популярным и простым способом обработки ошибок в C является анализ кодов возврата функций. Код обычно похож на приведенный ниже:

int  f( void );
. . .
{
  int    ReturnValue;

  . . .

  ReturnValue = f();
  if ( ReturnValue != 0 )
  {
      /* Какая-то обработка ошибки */
  }

  /* Ошибок нет */

В приведенном выше фрагменте накладным расходом процессорного времени являются операции сравнения кода возврата с некоторой величиной и, возможно, выполнение перехода. Это сравнение выполняется независимо от того, закончилось ли выполнение функции f() ошибкой или успехом. В C++-коде, использующем механизм исключений, кодов возврата не будет, не будет и оператора if. Соответственно, накладных расходов процессорного времени при успешном завершении функции не будет. Однако при генерации исключения накладные расходы на его обработку будут, скорее всего, больше, чем накладные расходы кода на C. Можно оценить время, затраченное на обработку исключения в коде на C++, и время, потраченное на проверку кода возврата в коде на C. Отношение этих величин даст некоторое число. Это число показывает минимальное количество успешных вызовов функции, при котором код на C++ будет работать эффективнее с точки зрения потребления процессорного времени, чем код на C. Если, например, число равно 220, то это означает, что если исключение генерируется реже, чем один раз на 220 вызовов, то код с использованием механизма обработки исключений будет работать быстрее, чем код, основанный на анализе кода возврата. Если же исключение генерируется чаще, чем один раз на 220 вызовов, то анализ кода возврата будет эффективнее.

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

Оптимизацияgcc 2.95gcc 3.3gcc 4.1intel 9.1
-O0265396394491
-O2N/A497445854
-O3 –fomit-frame-pointerN/A470609711
Таблица 40. Обработка исключений на платформе IA-32.
Оптимизацияgcc 2.96gcc 3.3gcc 4.1intel 9.1
-O0164232202465
-O25096355821445
-O3 –fomit-frame-pointer5126465051399
Таблица 41. Обработка исключений на платформе IA-64.
Оптимизацияgcc 2.95gcc 3.3gcc 4.1
-O0878884
-O2101112121
-O3 –fomit-frame-pointer107108270
Таблица 42. Обработка исключений на платформе Sun.
Оптимизацияgcc 3.4
-O0100
-O2102
-O3 –fomit-frame-pointer106
Таблица 43. Обработка исключений на платформе ARM.

Частоты, приведенные в таблицах, могут быть использованы разработчиками при выборе в пользу того или иного способа обработки ошибок. Интересен факт, что на фоне сравнимых результатов различных версий компилятора gcc, компилятор компании Intel демонстрирует результаты в 2 – 2.5 раза хуже.

Компилятор gcc 2.95 на платформе IA-32 при включении оптимизации генерировал код, приводящий к аварийному завершению работы программы. В связи с этим, в соответствующих ячейках таблицы результаты отсутствуют.

Библиотека IOStream

Библиотека ввода-вывода C++ пользуется репутацией неэффективной библиотеки. На производительность потоков ввода-вывода C++ может влиять режим синхронизации с потоками ввода-вывода C. Этот режим по умолчанию включен.

В тестовых программах производились операции вывода в файл целых в десятичном и в шестнадцатеричном формате, вывод чисел с плавающей точкой. Все операции выполнялись в двух вариантах – с включенной и отключенной синхронизацией потоков. Затем подобным образом выполнялись операции ввода. На рисунках ниже представлены результаты тестов. По вертикальной оси отложено время, затраченное на операции. Чем больше время и выше столбик, тем хуже производительность.


Рисунок 13. Операции ввода на платформе IA-32


Рисунок 14. Операции вывода на платформе IA-32


Рисунок 15. Операции ввода на платформе IA-64


Рисунок 16. Операции вывода на платформе IA-64


Рисунок 17. Операции ввода на платформе Sun


Рисунок 18. Операции вывода на платформе Sun


Рисунок 19. Операции ввода на платформе ARM


Рисунок 20. Операции вывода на платформе ARM

Производительность ввода-вывода с использованием потоков C++ во всех случаях, за одним исключением, оказалась хуже производительности ввода-вывода в стиле C. Наихудшие результаты – замедление порядка 600%. Исключение из правил, то есть лучшая производительность ввода-вывода в стиле C++, было продемонстрировано компилятором gcc серии 2. Однако это не дает поводов для оптимизма. Некоторые источники говорят о некорректной реализации ввода-вывода в стиле C++ в этом компиляторе с точки зрения соответствия требованиям стандарта. И, кроме того, этот компилятор на сегодняшний момент устарел и не рассматривается многими разработчиками как серьезный кандидат на работу с кодом C++.

Заключение

Современные компиляторы C++ демонстрируют высокое качество реализации новых языковых механизмов на всех платформах. Код на C++ практически не проигрывает в производительности функциональному эквиваленту на C, а в некоторых случаях позволяет получить и более высокое быстродействие программ. Досадным исключением остается потоковый ввод-вывод в стиле C++. Однако для этой ситуации есть обходной путь – компилятор C++ справится и с кодом ввода-вывода, написанным в стиле C. Кроме того, всегда остается надежда на разработчиков компиляторов. Библиотека ввода-вывода C++ неизбежно будет становиться эффективнее и эффективнее. А у компиляторов C, учитывая поддержку языком C++ большего количества подходов к проектированию программного обеспечения, будет оставаться все меньше и меньше шансов на использование в сложных проектах.

Автоматизация тестирования

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

Файл compilers.list

Файл находится в корне предлагаемой структуры каталогов и содержит список тестируемых компиляторов. Например:

# File format:
# first   compiler vendor
# second  c compiler path
# third   c++ compiler path

gcc4.1.1
/home/twinpeek/compilers/gcc/4.1.1/bin/gcc
/home/twinpeek/compilers/gcc/4.1.1/bin/g++

intel9.1
/home/twinpeek/compilers/intel/9.1.038/bin/icc
/home/twinpeek/compilers/intel/9.1.038/bin/icpc

В примере определены названия и пути к двум компиляторам – gcc серии 4 и компилятору компании Intel. Строки комментариев начинаются с символа ‘#’. В файле допускаются пустые строки.

Файл projects.list

Файл также находится в корне предлагаемой структуры каталогов и содержит список каталогов проектов, которые участвуют в тестировании. Например:

# File format:
# Pathes to the projects

abstraction_penalty/stepanov_test
abstraction_penalty/mitigation
abstraction_penalty/templates_boat_diff
abstraction_penalty/templates_boat_same

В примере определены четыре проекта, которые заданы в виде относительных путей. Строки комментариев начинаются с символа ‘#’. В файле допускаются пустые строки.

Файл optimization.info

Количество наборов ключей оптимизации, для которых выполняются тесты для каждого компилятора, определяется индивидуально для каждого проекта. Поэтому файл optimizations.info находится в каталоге каждого из проектов. Например, файл optimizations.info для проекта abstraction_penalty/stepanov_test может выглядеть так:

0
1
2

Каждая строка задает название набора ключей оптимизации. Здесь выбраны цифры в качестве названий.

Ключи оптимизации

Поиск ключей оптимизации компилятора, соответствующих каждому набору из файла optimizations.info производится следующим образом. Формируется имя файла по принципу:

<НазваниеКомпилятора>_opt<НазваниеНабораКлючей>.flags

Например, для компилятора компании Intel и последнего набора ключей оптимизации будет сформировано имя:

intel9.1_opt2.flags

Поиск этого файла будет производиться в каталоге flags соответствующего проекта. Если файл не найден в каталоге flags соответствующего проекта, то будет произведен поиск файла с таким же именем в каталоге flags, находящемся в корне предлагаемой структуры каталогов. В файле должны быть определены две переменные – для C- и C++-компилятора – с ключами оптимизации. Например, файл intel9.1_opt2.flags может быть таким:

CFLAGS=-O3 -fomit-frame-pointer
CPPFLAGS=-O3 -fomit-frame-pointer

Таким образом, поддерживается возможность иметь общие настройки ключей оптимизации для всех проектов и возможность тонкой индивидуальной настройки ключей оптимизации для конкретного проекта.

Запуск

Запуск компиляции всех проектов и сбора результатов осуществляется командой

./do_test.sh > TestResults.log

Литература

  1. Обзор компилятора gcc 4.0. http://www.coyotegulch.com/reviews/gcc4/index.html
  2. Отчет о производительности C++. http://www.open-std.org/jtc1/sc22/wg21/docs/TR18015.pdf
  3. Дэвид Вандервуд, Николаи М. Джосаттис. Шаблоны C++. Справочник разработчика. Вильямс, 2003 http://www.books.ru/shop/books/122949
  4. Бьерн Страуструп. Язык программирования C++. Специальное издание. Бином, 2004 http://www.books.ru/shop/books/84700
  5. Скотт Мейерс. Эффективное использование C++. 50 рекомендаций по улучшению ваших программ и проектов. ДМК, 2000 http://www.books.ru/shop/books/391847
  6. Скотт Мейерс. Наиболее эффективное использование C++. 35 новых рекомендаций по улучшению ваших программ и проектов. ДМК, 2000 http://www.books.ru/shop/books/391846
  7. Стефан К. Дьюхэрст. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ. ДМК, 2006 http://www.books.ru/shop/books/403739
  8. Джонатан Шиллинг. Оптимизация обработки C++ исключений. http://sco.com/developers/products/ehopt.pdf


Эта статья опубликована в журнале RSDN Magazine #2-2007. Информацию о журнале можно найти здесь
    Сообщений 26    Оценка 240        Оценить