46

以下来自 Scott Meyers 新 C++11 书的草稿说(第 2 页,第 7-21 行)

展开调用堆栈和可能展开它之间的区别对代码生成有惊人的巨大影响。在 noexcept 函数中,如果异常会传播到函数之外,优化器不需要将运行时堆栈保持在不可回退状态,也不必确保在异常离开函数时以相反的构造顺序销毁 noexcept 函数中的对象. 结果是更多的优化机会,不仅在 noexcept 函数的主体内,而且在调用该函数的位置。这种灵活性只存在于 noexcept 函数中。具有“throw()”异常规范的函数缺少它,根本没有异常规范的函数也是如此。

相比之下,“C++ 性能技术报告”部分5.4描述实现异常处理的“代码”和“表格”方式。特别是,“table”方法在没有抛出异常时没有时间开销,只有空间开销。

我的问题是——Scott Meyers 在谈到展开与可能展开时所谈论的优化是什么?为什么这些优化不适用throw()?他的评论是否仅适用于 2006 TR 中提到的“代码”方法?

4

4 回答 4

28

有“没有”开销,然后就没有开销。您可以以不同的方式考虑编译器:

  • 它生成一个执行某些操作的程序。
  • 它生成一个满足某些约束的程序。

TR 表示在表驱动方法中没有开销,因为只要不发生抛出就不需要采取任何行动。非异常执行路径直截了当。

但是,为了使表正常工作,非异常代码仍然需要额外的约束。在任何异常可能导致其破坏之前,每个对象都需要完全初始化,从而限制了指令的重新排序(例如,来自内联构造函数)在潜在的抛出调用中。同样,必须在任何可能的后续异常之前完全销毁对象。

基于表的展开仅适用于遵循 ABI 调用约定和堆栈帧的函数。在没有异常的可能性的情况下,编译器可以自由地忽略 ABI 并省略框架。

空间开销,又称膨胀,以表格和单独的异常代码路径的形式,可能不会影响执行时间,但它仍然会影响下载程序并将其加载到 RAM 所花费的时间。

这都是相对的,但noexcept会使编译器有些松懈。

于 2014-09-27T23:07:14.077 回答
13

noexcept和之间的区别在于throw(),如果throw()异常堆栈仍然未展开并调用析构函数,因此实现必须跟踪堆栈(参见15.5.2 The std::unexpected() function标准)。

相反,std::terminate()不需要解开堆栈(15.5.1声明在调用之前是否解开堆栈是实现定义的std::terminate())。

GCC 似乎真的没有展开堆栈noexceptDemo
虽然 clang 仍然展开:Demo

(您可以在演示中评论f_noexcept()和取消评论f_emptythrow()以查看throw()GCC 和 clang 的堆栈展开)

于 2014-09-28T00:03:06.653 回答
10

举个例子:

#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它,它也是一个重要的语义指示。

于 2014-09-27T23:34:54.063 回答
1

我刚刚做了一个基准测试,以测量添加“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”产生了显着的性能提升。

于 2019-01-25T21:43:45.970 回答