noexcept,堆栈展开和性能

noexcept, stack unwinding and performance

本文关键字:性能 堆栈 noexcept      更新时间:2023-10-16

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

展开调用堆栈和可能展开调用堆栈之间的区别在于对代码生成的巨大影响令人惊讶。在noexcept函数中,优化器如果异常传播到函数之外,也必须确保noexcept中的对象如果出现异常,函数将按构造的相反顺序销毁离开函数。结果是有更多的优化机会,不仅在noexcept函数的主体内,也可以在函数所在的位置呼叫。这种灵活性仅适用于noexcept函数。具有的函数"throw()"异常规范缺少它,完全没有异常规范的函数也是如此。

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

我的问题是,当Scott Meyers谈到解除与可能解除时,他在谈论什么优化?为什么这些优化不适用于throw()?他的评论是否只适用于2006年TR中提到的"代码"方法?

有"无"开销,然后有开销。你可以用不同的方式来思考编译器:

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

TR表示,在表驱动的appaoch中没有开销,因为只要不发生投掷,就不需要采取任何行动。非异常执行路径直接向前。

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

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

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

这一切都是相对的,但noexcept让编译器松了一口气。

noexceptthrow()之间的区别在于,在throw()的情况下,异常堆栈仍然是展开的,并且调用了析构函数,因此实现必须跟踪堆栈(请参阅标准中的15.5.2 The std::unexpected() function)。

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

GCC似乎真的没有展开noexcept:Demo的堆栈
当叮当声仍然解开:演示

(您可以在演示中注释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);
}

从编译器的角度来看,作为输入传递的数据是不可预见的,因此即使使用CCD_。

在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
}

正如您所看到的,即使在正常控制流的情况下很简单(没有例外),代码路径现在也由同一函数中的几个基本块分支组成。

的确,在运行时几乎没有任何成本,因为你为你使用的东西付费(如果你不扔,就不会发生任何额外的事情),但拥有多个分支也可能会影响你的性能,例如

  • 分支预测变得更加困难
  • 寄存器压力可能大幅增加
  • [其他]

当然,您不能在正常控制流和着陆板/异常入口点之间运行直通分支优化。

异常是一种复杂的机制,即使在零成本EH的情况下,noexcept也能极大地缩短编译器的使用寿命


编辑:在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"获得了显著的性能提升。