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

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

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

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

Мой вопрос в том, что оптимизирует Скотт Мейерс, говоря о том, когда он говорит о развязывании и, возможно, раскручивании? Почему эти оптимизации не применяются для throw()? Используют ли его комментарии только к методу "кода", указанному в 2006 TR?

Ответ 1

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

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

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

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

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

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

Все это относительное, но noexcept разрезает компилятору некоторый слабину.

Ответ 2

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

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

GCC, похоже, действительно не разматывает стек для noexcept: Демо-версия
Пока clang все еще расслабляется: Демо

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

Ответ 3

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

#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 для оптимизации, это также важная семантическая индикация.

Ответ 4

Я только что сделал тест, чтобы измерить эффект производительности, добавив спецификатор "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" в этом конкретном тестовом примере.