Провёл на досуге исследование на тему сообщение об ошибках с помощью исключений против сообщения об ошибках с помощью возвращаемых значений с т.з. скорости выполнения кода и кодогенерации. Возможно кому-то ещё будет интересно.
Цель можно охарактеризовать примерно так: исследовать скорость работы некоторых ключевых паттернов кода, реализованных с применением исключений для сообщения об ошибках, или с помощью возвращаемых значений.
Что б не было скучно сразу начну с некоторых результатов.
Сейчас поясню, что что значит.
Компилировал со всеми оптимизациями, при этом следил, чтобы исследуемые функции _не_ встраивались. Время замерял с помощью rdtsc, соответственно результаты приведены в тактах процессора. Каждый тест запускал 1000 раз, потом находил минимальный результат.
Сразу оговорюсь, что на полную объективность не претендую. Какие результаты получил в своих конкретных условиях, при определённых настройках компилятора и т.д., такие и привожу. Но тем не менее, уверен, что основные тенденции уловлены правильно. Тем более сгенерированный код во всех случаях изучался на правдоподобность.
Выводы для такого тривиального случая с простыми функциями.
Исключения быстрее, когда реально исключения не кидаются. За счёт чего? За счёт дополнительных инструкций на проверку возвращаемого значения, и на переход (jmp).
Вход/выход в try блок [как ни странно] не влияет на производительность.
Кидание исключения очень дорого. Порядка 20мкс под Win/msvc71 и порядка 100мкс под Linux (почему так долго под Linux не знаю). А под msvc8 ухитрились сделать за 7мкс, хотя они всё равно реализованы поверх SEH.
В первом случае у исключений было небольшое приемущество, точнее немного неадекватный случай, т.к. программа не выделяла никаких ресурсов. Когда выделяются ресурсы для исключений начинается самое интересное, т.к. приходится помещать фреймы на стек, следить за созданием объектов и т.д. Теперь замерим скорость с ресурсами. Сразу поясню, что я подразумеваю под ресурсами. В условиях исключений под ресурсом подразумеваю объект класса с нетривиальным деструктором (в котором происходит освобождение ресурса) и с конструктором (который выделяет ресурс, при этом может провалиться и кинуть исключение). В условиях возвращаемых значений я рассмотрел 2 варианта. Первый – в стиле С++ — класс, в деструкторе также освобождает ресурс, а конструктор заменил на функцию bool init(), которая выделяет ресурс и сообщает о успешности. Второй вариант – в стиле С – ресурс представляет из себя некий int, и 2 функции int constructor(int*) и void destructor(int). Все функции выделения/освобождения ресурса сами по себе ничего не делают, кроме как вызываются.
Функции тестировал те же, только отличие в том, что функция в начале работы «выделяет» ресурс, потом «освобождает» ресурс и возвращается. Соответственно в присутствии исключений, т.к. у локального объекта есть деструктор, компилятору приходится создавать фреймы на стеке и т.д. Замерял только в «успешном» случае, т.к. если будет кидаться исключение, то уже понятно, что это будет настолько долго, что и сравнивать нечего.
Собственно результаты:
Для большей правдоподобности и интриги код в стиле С компилировал именно как С код, а не С++, соотв. пользовался не g++, а gcc.
Выводы для случая с функциями с выделением ресурса.
Под msvc71 всё ровненько – даже не интерсно. Т.е. накладные расходы на проверки возвращаемых значений скомпенсировались накладными расходами на поддержку исключений.
gcc4.1.1 под mingw как-то совсем неадекватно себя ведёт при необходимости освобождать ресурсы... (почему так не знаю)
gcc3.2.2 под Linux – результаты примерно одинаковые. Только в С++ при использовании возвращаемых значений накладываются сразу 2 типа накладных расходов, поэтому получается значительно дольше.
Теперь следующий тест. Очень много функций и очень много ресурсов. Чтобы поглядеть как ведёт себя производительность в асимптотике.
Функции такого вида:
F6()
{
повторить 6 раз // в коде это записано не в виде цикла, а развёрнуто
{
выделяем 6 ресурсов
вызываем F5();
}
}
Соответственно функция F5 аналогичная, только вызывает F4, и так далее. F0 – ничего не делает – сразу возвращается.
В С варианте это было записано примерно так:
int f6()
{
int o1, o2;
int res = 0;
if (!constructor(&o1)) goto end;
if (!constructor(&o2)) goto do1;
if (!f5()) goto do2;
res = 1;
do2: destructor(o2);
do1: destructor(o1);
end: return res;
}
только экстраполировано на выделение 6 ресурсов и это всё повторяется 6 раз.
В варианте с++/rv:
bool f6()
{
obj o1, o2;
if (!o1.init()) return false;
if (!o2.init()) return false;
if (!f5()) return false;
return true;
}
В варианте с++/ex:
bool f6()
{
obj o1, o2;
f5();
}
Результаты:
Ну что тут можно сказать. В принципе код с исключениями немного быстрее, но совсем немного (но это уже приятно
). Единственное он медленнее на gcc41, но вообще как-то совсем неадекватно сгенерировал код...
Ещё я проводил аналогичный тест, но во время выполнения эмулировал возникновение ошибки (либо кидалось исключение, либо возвращался false). Результаты для краткости приводить не буду. Но суть такая, что в данном случае код с исключениями всё равно остаётся быстрее, т.к. время работы функции очень большое и за ним время кидания исключения нивелируется.
Так же есть ещё один аспект, влияющий на производительность, но который ускользает при таких замерах – размер сгенерированного кода. Чем больше кода – тем больше промахов кэша и переходов через границы страницы памяти – это может достаточно сильно влиять на производительность. Соответственно я замерил размер кода для одной функции типа f6(), которые я приводил выше. Результаты:
Замерял на msvc71. instr – кол-во ассемблерных команд. Size – размер функции в байтах.
У исключений значительно меньше инструкций, тем не менее размер кода равен размеру кода на С с возвращаемыми значениями. Как ни странно... Связано с тем, что команды там больше по размеру. Код на С++ с возвращаемыми значениями тут сильно проигрывает – так там инструкции и на проверки возвращаемых значений и на поддержку исключений.
Так же провёл следующий тест. Функции аналогичные предыдущим, но не выделяются ресурсы – просто идёт много форвардов на другие функции, а те на другие и т.д.
Размер кода:
Тут картина уже интереснее – исключения значительно вырываются вперёд. На msvc на 50% (!) на gcc на 35% (!). По размеру код на 136% (!).
Тут можно сделать важный вывод – если есть функции, в которых нет локальных переменных/аргументов с нетривиальными деструкторами – при использовании исключений они будут работать до 50% быстрее и размер кода будет меньше до 136%.
Попутно я исследовал следующий вопрос – имеет ли смысл передавать умные указатели по ссылке вместо передачи по значению. Мы зачастую (ну я по-крайней мере
) передаю просто boost::shared_ptr без ссылок – по значению.
Функции аналогичные функциям из предыдущего теста – просто много форвардинга вызовов функций, без выделения ресурсов, но при этом между функцими передавался один boost::intrusive_ptr, в одном случае по значению, в другом по ссылке.
Размер кода:
Разница поразительная. При передаче по ссылке скорость выросла в 4.18 раза, размер кода уменьшился в 5.13 раза. Накладные расходы идут не только на увеличения/уменьшения счётчика, но так же на поддержание фрейма на стеке, и на контроль конструирования объектов – что бы знать какие рушить при исключении.
Более реалистичный тест по количеству ресурсов и вызовов функций. С исключениями тестовые функции выглядят так:
void f6()
{
obj o1, o2, o3;
f5();
f5();
}
f5() выглядит аналогично, только вызывает f4() и т.д. f0 ничего не делает. Код с возвращаемыми значениями аналогичный, только... переписан соответственно с возвращаемыми значениями. Получается 64 вызова самой вложенной функции и примерно 200 объектов с деструкторами на тест, что более менее сравнимо с некой функциональностью в современной программе.
Смотрим, что получилось:
Тут получается, что исключения чуточку впереди (не считая gcc41 под mingw, который опять неадекватно сгенерировал код).
Далее сделал следующее – из 100 вызовов функции 1 проваливается (кидается исключение, или возвращается код возврата с ошибкой), и я беру не менимальное из всех замеров время, а усредняю по всем 100 замерам. Т.о. эмулируется ситуация, что в программе иногда происходит ошибка.
Смотрим, что получилось:
Тут уже видно, что исключения и возвращаемые значения практически сравнялись – где-то уже немного отстали, где-то уже почти отстали. Соответственно, если увеличивать процент провалов, то исключения уже начнут существенно отставать.
Общие выводы какие возникли – возвращаемые значения типа писсимистической стратегии – т.е. чуть что сделали сразу проверяем, а не плохо ли всё, исключения – оптимистическая стратегия – делаем всё подряд без разбора и проверок, авось всё хорошо.
В принципе возникает такое ощущение, что исключения всё-таки немного быстрее (если не происходит ошибок). Соответственно если для какой-то функции вероятность провала более 1%, то надо использовать коды возврата, если менее 1%, то надо использовать исключения для повышения производительности. Да-да, вы не ослышались – для поднятия производительности надо использовать исключения.
Так же стоит отметить, что в msvc8 по сравнению с msvc71 резко снизилась стоимость кидания исключения. Но тут у них есть предел, т.к. исключения реализованы поверх SEH, значит кидание исключения всегда будет связано с переходом в ядро.
Хочется ожидать большего от gcc, т.к. они не ограничены этим фактором и могут сделать действительно быстрые исключения. Сейчас уже есть некоторые исследования на тему реализации быстрых исключений, будем надеятся, что их воплотят в gcc в ближайшем будущем. Тогда чаша весов ещё более сильно перевесит в сторону исключений.
Так же совершенна непонятна ситуация с gcc4.1.1/mingw – и кодогенерация какая-то непонятная и исключения ооочень медленные.
Так же очевидно, что использование возвращаемых значений под С++ — это совсем плохая идея – издержки от двух стихий, а плюсов нет в противопоставление с возвращаемыми значениями под С.
Ещё момент — при использовании исключений принципиально различается кодогенерация в зависимости от того, есть ли в функции локальные объекты/аргументы с нетривиальными деструкторами — если нет, то просто идут вызовы других функций, если есть, то тут уже начинается: занесение фрейма на стек, подсчёт сконструированных объектов, снятие фрейма со стека. Получается значительная разница и по скорости и по размеру кода.
Ну вот собственно всё, что хотелось сказать. Коменты и замечания преведствуются
Особенно интересно было бы услышать, если я пропустил какие-то важные аспекты в исследовании, которые могут сущуственно сказаться на производительности/размере кода/или ещё чём-то в разрезе исключения/возвращаемые значения.