错误代码远比异常慢

2020-11-25 05:19:49

C被认为是最快的编程语言。 C ++具有仅使C更加便捷而不影响性能的功能以及影响性能的功能。它们对提高代码质量有很大帮助,因此无论如何都经常使用它们。运行时多态性实际上无处不在,例外情况很少。

不使用例外的完全正当理由是,可执行文件的大小受到平台限制的限制,或预计会受到平台限制的严格限制。不使用它们的一个可疑原因是性能,因为全新功能不可能在不妥协的情况下正常工作。同样,在错误的情况下使用异常可能会完全破坏性能,因为处理抛出的异常非常昂贵。

但是性能影响有多重要?在大多数现代的64位平台上,只要不抛出异常,就可以以最小化其代价的方式实现异常。不会检查生成的函数中是否抛出异常,在处理异常时,执行将切换为特殊函数和特殊数据。但是,不使用异常也不是免费的。罕见错误必须以某种方式处理。一种可能是使程序简单地中止,在磁盘上留下任何损坏的状态,从而导致非常烦人的用户体验(例如,在Unreal Engine和Unity Engine中所做的操作,其中代码中的API使用不正确会导致编辑器崩溃并一直崩溃直到不正确的二进制文件将被手动删除)。另一个替代方法是错误代码,当函数报告它们失败并且调用代码应该正确响应时,这对于程序员来说不太方便,并且从函数返回后需要对程序进行额外的检查,但是,出于性能原因通常这样做。

但是实际上,这些方法如何影响性能?我已经在模拟电子游戏典型用例的实际示例中对此进行了测试。

顾名思义,异常应该用于处理特殊情况。例外情况是规则不适用的情况。在软件中,这意味着事情没有按预期进行。不是用例的一部分。失败。无效的用户输入,连接失败,数据损坏,数据包无效,设备初始化失败,文件丢失,程序员错误…

在许多情况下,该程序不应只是中止。无效的用户输入使程序停止工作非常烦人,因为这会导致所有未保存的数据丢失,并迫使用户等待程序重新启动。连接失败是一个非常可恢复的问题,通常只需重新连接即可解决。导致程序崩溃的无效数据包是破坏的门户,因为任何人都可以发送无效数据包以导致程序崩溃。这就是可以通过异常解决的方法。抛出它们很慢,但是不需要针对用例进行优化。

错误使用异常的示例是在一切正常运行时引发异常的情况。从双循环中断,处理容器的末端,检查是否可以反序列化一个数字,否则将使用默认值…

现代的64位体系结构使用一种称为零成本异常的模型,该模型在不抛出异常的情况下强烈优化了异常路径,从而极大地支持了异常路径的执行,而在实际抛出异常时却以非常糟糕的性能为代价。

换句话说,应该可以在启用异常停止功能的调试器中运行程序。

尽管并非所有错误处理都可以有效地处理异常,但错误代码可以处理所有错误。问题是,他们应该吗?

为了进行此测试,我编写了一个XML解析器。我之所以选择编写一个解析器,是因为它可能在许多位置失败并且不依赖于I / O。它绝对不符合标准,也不保证在所有可能的无效输入上都会失败,但是它可以解析常规的XML配置文件,并且在大多数情况下在语法上不正确的情况下应该以错误结尾。该代码级别很低,应该相对较快(大约150 MiB / s),但是我没有对其进行优化,而是使用STL容器使其使用起来很方便(与原位解析相反)。我使用大量的#ifdef检查来编写它,以便仅使用编译器参数在异常,错误代码之间进行切换并在错误时中止,从而确保变体之间的唯一区别将是不同错误处理所必需的。

我用模仿视频游戏配置的XML文件对它进行了基准测试。它的大小为32 kiB,在基准测试开始之前已加载到内存中。重复分析10000次并平均持续时间,然后重复10次以测试其不准确性低于1%。

该代码是在Ubuntu 20.04上使用GCC 9编译的,并带有最大单线程频率为4.5 GHz的Intel i7-9750H处理器。我进行了所有我想在相似的时间进行比较的实验,在两者之间不做任何事情,以平衡占用缓存的其他程序的影响。无论如何,仍然有离群值明显超过平均值。我删除了这些。

由于错误而中止的版本与带有异常的版本一样快。带有错误代码的版本慢了5%。

由于某些原因,如果通过打印错误并退出程序的特殊功能处理了故障,则由于某些原因,它比带有异常的版本慢(约1%)。我必须使用宏使其与使用异常的代码速度相当。在其他测试中重复此行为。

在此测试中,我编写了一些类来表示XML文件中的结构以及用于使用已解析的XML结构填充数据的代码。这部分快了大约10倍,这可能是因为动态分配少了很多。

带有异常的代码的错误容限和没有适当错误处理的代码的错误容限重叠,但是异常的时间增加了0.6%。对于错误代码,该程序要慢4%。我通过忘记使用移动语义实现了类似的速度下降。

该测试模仿了使用异步API从TCP套接字(例如Boost Asio或Unix套接字)读取数据的用法。这些API的使用方式始终是从流中读取一定数量的字节,必须对其进行处理,然后再读取更多数据。为了更快地处理并减少带宽,数据采用二进制形式。由于视频游戏中的网络数据是连续流式传输的,因此等待结束是不可行的。

通信由标识不同可能更新的三种消息类型表示。由于消息的长度不同,因此无法准确确定所有消息的长度是否可用,因此即使一切正常运行,标识消息并调用适当的解析代码的功能也会经常失败-因此无法使用异常来处理此类故障。其他故障,如无法识别的消息类型,对象的错误标识或值的突然大变化(作弊或数据损坏),仍由异常处理(在使用它们的情况下)。

从内存中读取数据是为了防止网络影响测试。数据是由此脚本生成的。

测试的结果与以前的测试类似–使用异常进行错误处理的代码比由于错误而中止的代码慢0.8%(在误差范围内),而使用错误代码处理错误的代码则慢6%。慢点。

下表总结了基准测试所花费的时间,并按比例进行了调整,以使发生错误时中止版本的所需时间为100%。

不精确度大约为1%,因此使用异常的版本可能不会真的慢一点,并且差异可能是偶然的结果或某些无形的编译器决定(例如内联)的结果。使用错误代码的版本所需的时间始终较长。

如果在块中未处理异常,则执行将自动退出该块,直到找到可以捕获该异常的代码为止。任何其他类型的处理都不支持此操作,并且需要编写其他逻辑来处理故障,尽管在几乎所有情况下,适当的反应都是中止程序正在执行的操作(通过读取流进行的测试就是这样做的一个示例)不适用)。即使对正在调用的函数的任何失败的反应是将错误代码返回给调用者的调用者,这也可以大大延长代码的长度。

它将子XML标记(称为其参数的animation)转发给名为animation的成员类的构造函数。构造函数可能由于XML标签内容不正确而失败,或者getChild函数可能由于缺少整个标签而失败。这会中止结构的创建,或者中止catch程序中程序中的其他过程。

如果错误是通过返回值(或输出参数)宣布的,则代码必须看起来像这样:

可以使用宏将其缩短(通常,lambda可以代替宏,但不能替代此宏):

即使宏隐藏了更多重复的部分,代码也要多出三倍!

除了需要更多代码之外,它还使RAII的可用性降低,因为构造函数如果成功则无法返回信息,因此需要初始化函数或特殊函数,如果构造顺利,则返回这些函数。这进一步使代码复杂化。

以这种方式编写代码没有任何好处-额外的行使逻辑与其他定义,输出参数和宏复杂化,错误可能会被意外忽略,RAII无法正确使用,并且由于以下原因,代码必须是异常安全的:早期回报的数量。

在调试模式下,使用异常会降低性能。它保持在2%左右,因此仍比错误代码快。

添加许多其他(不必要的)try块会对性能产生负面影响。看起来try块是否可能导致使用异常处理的版本性能略有下降,但实验并未证实这一点。

在使用异常时禁用RTTI似乎对try块的速度没有任何影响(在那种情况下,必须将异常对象捕获为catch(...),不可访问,并且错误消息必须存储在其中一个thread_local变量)。

如果实际引发异常,则数字将变得非常不同。每个退出的函数处理异常大约需要2微秒。虽然数量不多,但是这相当于大约有成千上万个带有错误代码的函数调用导致的速度下降。因此,如果抛出异常的概率大约高于0.01%,则异常是无效的。这对使用异常的地方没有太大影响-目的是使程序在正确使用的情况下能够快速运行。尽管它应该正常运行,但并不需要针对故障进行优化。

异常确实增加了可执行文件的大小。标记程序的可执行文件从74.5 kiB缩减为64 kiB。同时禁用RTTI可以将大小减小到54.8 kiB。我没有测试增加的数量是多少,可乘的是多少,但是应该警告,可能需要在某些更受限的嵌入式平台上禁用例外。

我还使用编译器资源管理器分析了生成的程序集。启用异常不会改变函数体(即,没有分支或附加的返回值),但函数以通常无法访问的异​​常处理代码块结尾(try块也是通常无法访问的代码块)。该代码称为析构函数,并将控制权返回给异常处理函数。该代码尽管未执行,但却占用了缓存(类似于早期返回)。对于未使用析构函数或标记为noexcept的函数进行堆栈分配的函数,不会生成此代码。因此,不需要处理错误的性能关键代码可以使用noexcept进行优化,如果它使用了可以抛出的东西,则可以通过避免使用析构函数堆叠分配对象来优化代码(但是,我还没有测试C ++是否用作C除外,比C更快。

处理错误时,异常是处理错误的最便捷方法。我已经在现实的基准上测试了其性能含义。在64位体系结构上,启用异常的成本与发生错误时中止的成本相比不到1%。处理可恢复的错误,错误代码的常用替代方法会使性能降低约5%。因此,禁用PC体系结构上的异常不仅不方便,而且可能对性能有害。

跳回到主导航