noexcept, разматывание стека и производительность

В следующем наброске из новой книги С ++ 11 Скотта Мейерса говорится (стр. 2, строки 7-21)

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

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

Мой вопрос заключается в следующем: о каких оптимизациях говорит Скотт Мейерс, когда говорит о том, что раскручивать или раскручивать? Почему эти оптимизации не применяются для throw()? Применяются ли его комментарии только к методу "кода", упомянутому в ТР 2006 года?

3 ответа

Решение

Там "нет" накладных расходов, а затем нет накладных расходов. Вы можете думать о компиляторе по-разному:

  • Он генерирует программу, которая выполняет определенные действия.
  • Он генерирует программу, удовлетворяющую определенным ограничениям.

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

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

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

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

Это все относительно, но noexcept немного ослабляет компилятор.

Разница между noexcept а также throw() это в случае throw() стек исключений все еще разматывается и вызываются деструкторы, поэтому реализация должна следить за стеком (см. 15.5.2 The std::unexpected() function в стандарте).

Напротив, std::terminate() не требует разматывания стека (15.5.1 заявляет, что это определяется реализацией независимо от того, был ли стек размотан раньше std::terminate() называется).

GCC, похоже, действительно не раскручивает стек для noexcept: Демо
Пока лязг все еще раскручивается: Демо

(Вы можете комментировать f_noexcept() и раскомментируйте f_emptythrow() в демонстрациях, чтобы увидеть, что для throw() и GCC, и Clang раскручивают стек)

Возьмите следующий пример:

#include <stdio.h>

int fun(int a) {

  int res;
  try
  {
    res = a *11;
    if(res == 33)
       throw 20;
  }
  catch (int e)
  {
    char *msg = "error";
    printf(msg);
  }
  return res;
}

int main(int argc, char** argv) {
  return fun(argc);
}

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

В LLVM IR fun функция примерно переводится как

define i32 @_Z3funi(i32 %a) #0 {
entry:
  %mul = mul nsw i32 %a, 11 // The actual processing
  %cmp = icmp eq i32 %mul, 33 
  br i1 %cmp, label %if.then, label %try.cont // jump if res == 33 to if.then

if.then:                                          // lots of stuff happen here..
  %exception = tail call i8* @__cxa_allocate_exception(i64 4) #3
  %0 = bitcast i8* %exception to i32*
  store i32 20, i32* %0, align 4, !tbaa !1
  invoke void @__cxa_throw(i8* %exception, i8* bitcast (i8** @_ZTIi to i8*), i8* null) #4
          to label %unreachable unwind label %lpad

lpad:                                             
  %1 = landingpad { i8*, i32 } personality i8* bitcast (i32 (...)* @__gxx_personality_v0 to i8*)
          catch i8* bitcast (i8** @_ZTIi to i8*)
 ... // also here..

invoke.cont:                                      
  ... // and here
  br label %try.cont

try.cont:        // This is where the normal flow should go
  ret i32 %mul

eh.resume:                                        
  resume { i8*, i32 } %1

unreachable:                                    
  unreachable
}

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

Это правда, что во время выполнения почти ничего не связано с тем, что вы платите за то, что используете (если вы не выбрасываете, ничего лишнего не происходит), но наличие нескольких веток может также повредить вашей производительности, например

  • предсказание ветвления становится все труднее
  • давление в регистре может существенно возрасти
  • [другие]

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

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


Изменить: в конкретном случае noexcept спецификатор, если компилятор не может "доказать", что ваш код не генерирует, std::terminate EH настроен (с деталями, зависящими от реализации). В обоих случаях (код не генерирует и / или не может доказать, что код не генерирует) задействованная механика проще, а компилятор менее ограничен. Во всяком случае, вы не используете noexcept по причинам оптимизации это также важный смысловой признак.

Я только что сделал тест, чтобы измерить эффект производительности, добавив спецификатор "noexcept", для различных тестовых случаев: https://github.com/N-Dekker/noexcept_benchmark У него есть специальный тестовый пример, который может воспользоваться возможностью пропустить разматывание стека с помощью noexcept:

void recursive_func(recursion_data& data) noexcept // or no 'noexcept'!
{
  if (--data.number_of_func_calls_to_do > 0)
  {
    noexcept_benchmark::throw_exception_if(data.volatile_false);
    object_class stack_object(data.object_counter);
    recursive_func(data);
  }
}

https://github.com/N-Dekker/noexcept_benchmark/blob/v03/lib/stack_unwinding_test.cpp#L48

Глядя на результаты тестов, кажется, что и VS2017 x64, и GCC 5.4.0 дают значительный выигрыш в производительности за счет добавления "noexcept" в этом конкретном тестовом примере.

Другие вопросы по тегам