C++比Rust更快更安全:Yandex进行了基准测试

2020-05-10 19:51:29

剧透:C++既不快也不慢--实际上,这不是重点。这篇文章延续了我们的良好传统,打破了一些俄罗斯大牌公司分享的关于铁锈语言的神话。

注意。本文最初发表在Habr.com上。在获得作者许可的情况下,它被翻译并转载到了这里。

本系列的前一篇文章的标题是“走得比锈快”,Mail.Ru得到了测量(RU)";。不久前,我试图引诱我的同事--另一个部门的C程序员--加入Rust。但我失败了,因为我要引用他的话:

2019年,我参加了C++CoreHard大会,参加了Anton@antoshkka Polukhin关于不可或缺的C++的演讲。根据他的说法,铁锈是一种年轻的语言,它不是那么快,甚至也不是那么安全。

Anton Polukhin是俄罗斯在C++标准化委员会的代表,也是几个已接受的C++标准提案的作者。在所有与C++相关的事情上,他确实是一个杰出的人物和权威。但他的讲话有几个关于拉斯特的关键事实错误。让我们看看它们是什么。

我们特别感兴趣的安东演讲(RU)部分是13:00到22:35。

为了比较这两种语言的汇编输出,Anton选择了平方函数(link:Godbolt)作为示例:

我们得到相同的汇编输出。太棒了!我们已经拿到基线了。到目前为止,C++和Rust都产生了相同的输出。

事实上,算术乘法在这两种情况下都会产生相同的汇编清单-但仅限于此。问题是-上面的两个代码片段在语义上做了不同的事情。当然,它们都实现了平方函数,但是对于Rust,适用的范围是[-2147483648,2147483647],而对于C++,它的适用范围是[-46340,46340]。怎么会这样?。魔法?

幻数常量-46340和46340是最大的绝对值参数,其平方适合std::int32t类型。超过该值的任何内容都会由于有符号整数溢出而导致未定义的行为。如果你不相信我,去问PVS-Studio。如果您有幸所在的团队设置了具有未定义行为检查的配置项环境,您将收到以下消息:

出现未定义行为的原因是我们使用了有符号的值,而C++编译器假定有符号的整数值不会溢出,因为这将是未定义的行为。编译器依赖于这一假设来进行一系列棘手的优化。在“铁锈”中,这种行为是有记录在案的,但它不会让你的生活变得更轻松。无论如何,您都会得到相同的汇编代码。在铁锈中,这是一种记录在案的行为,将两个大的正数相乘将产生负数,这可能不是您所期望的。更重要的是,记录这一行为可以防止Rust应用它的许多优化--它们实际上列在他们的网站上的某个地方。

我想了解更多Rust无法做到的优化,特别是考虑到Rust是基于LLVM的,而LLVM与Clang基于的后端是相同的。因此,Rust免费继承了大部分与语言无关的代码转换和优化,并与C++共享。上例中的程序集清单完全相同,这实际上只是巧合。C++中由于签名溢出而导致的棘手的优化和未定义的行为对于调试和启发像本文(RU)这样的文章来说是非常有趣的。让我们仔细看看吧。

我们有一个函数可以计算具有整数溢出的字符串的多项式散列:

在某些字符串上--特别是在拜拜--而且只在服务器上(有趣的是,在我朋友的计算机上一切正常),该函数会返回一个负数。但是为什么呢?如果该值为负值,则向其添加MAX_INT,从而产生正值。

托马斯·波尔宁(Thomas Pornin)表明,未定义的行为实际上是未定义的。如果您将值27752提高到3的幂,您就会明白为什么两个字母的散列计算是正确的,但在三个字母上却得到了一些奇怪的结果。

由于众所周知的原因,此代码在调试和发布模式下的执行方式不同,如果您想要统一行为,可以使用以下函数族:WRAPPING*、SANTURATING*、OVERFLOW*和CHECKED*。

正如您所看到的,记录的行为和由于签名溢出而导致的未定义行为确实使生活变得更容易。

将数字平方就是一个很好的例子,说明了如何仅用三行C++代码就可以砸自己的脚。至少您可以以一种快速和优化的方式做到这一点。虽然可以通过仔细检查代码来捕获未初始化的内存访问错误,但在纯粹的算术代码中,与算术相关的错误会突如其来,您甚至不会怀疑它有任何可能被破坏的东西。

Rust编译器和C++编译器都编译了该应用程序,并且.。bar函数什么也不做。两个编译器都发出了可能出错的警告。我说的是什么意思?当您听到有人说Rust是一种超级酷和安全的语言时,只需知道它唯一安全的事情就是对象生命周期分析。您可能没有预料到的UB或记录的行为仍然存在。编译器仍然编译显然没有意义的代码。好吧.。就是这样。

我们在这里处理的是无限递归。同样,两个编译器生成相同的汇编输出,即C++和Rust都为bar函数生成NOP。但这实际上是LLVM的一个缺陷。

如果您查看无限递归代码的LLVM IR,您将看到以下内容(link:Godbolt):

该漏洞自2006年以来一直存在于LLVM中。这是一个重要的问题,因为您希望能够标记无限循环或递归,以防止LLVM将其优化为零。幸运的是,情况正在改善。LLVM6发布时添加了固有的llvm.side效应,在2019年,rustc获得了-Z插入侧效应标志,这会将llvm.side效应添加到无限循环和递归中。现在,无限递归被认为是这样的(链接:Godbolt)。希望这个标志也能很快被添加为稳定锈蚀的默认标志。

在C++中,没有副作用的无限递归或循环被认为是未定义的行为,所以这个LLVM错误只影响Rust和C。

现在我们已经澄清了这一点,让我们来解决Anton的关键语句:它唯一安全的地方是对象生存期分析。这是一个错误的陈述,因为Rust的安全子集使您能够在编译时消除与多线程、数据竞争和内存快照相关的错误。

让我们来看看更复杂的函数。拉斯特用它们做什么?我们已经修复了bar函数,现在它可以调用foo函数。您可以看到Rust已经生成了两条额外的指令:一条将某些内容推入堆栈,另一条在堆栈末尾从堆栈中弹出一些内容。在C++中不会发生这样的事情。生锈已经两次触动了记忆。那可不好。

Rust的汇编输出很长,但我们必须找出它与C++的不同之处。在本例中,Anton对C++使用-ftrapv标志,对Rust使用-C overflow-check=on来启用签名溢出检查。如果发生溢出,C++将跳转到ud2指令,这将导致";非法指令(CORE转储)";,而Rust则跳转到调用CORE::FARGING::FARGIC函数,该函数的准备工作占据了清单的一半。如果发生溢出,CORE::FARNING::FARIC将输出一个很好的解释,说明程序崩溃的原因:

那么,这些涉及内存的额外指令从何而来呢?x86-64调用约定要求堆栈必须与16字节边界对齐,而调用指令会将8字节的返回地址推入堆栈,从而破坏对齐。要解决这个问题,编译器会推送各种指令,如PUSH RIX。不仅仅是Rust-C++可以做到这一点(链接:Godbolt):

C++和Rust都生成了相同的程序集清单;出于堆栈对齐的目的,两者都添加了Push RBX。Q.E.D。

最奇怪的是,实际上需要取消优化的是C++,方法是添加-ftrapv参数来捕获由于签名溢出而导致的未定义行为。在前面,我展示了Rust即使没有-C overflow-checkks=on标志也可以做得很好,所以您可以自己检查正确运行C++代码的成本(link:godbolt)或阅读本文。此外,从2008年开始,GCC的-ftrapv就被打破了。

在整个演讲过程中,Anton选择了编译成稍大的汇编代码的Rust代码示例。这不仅适用于上面的例子,那些触动记忆的例子,也适用于17:30讨论的那个例子(link:Godbolt):

看起来似乎所有这些汇编输出的分析都是为了证明更多的汇编代码意味着更慢的语言。

在2019年的CppCon大会上,钱德勒·卡鲁斯做了一个有趣的演讲,题目是“没有零成本的抽象”。在17:30,您可以看到他抱怨std::ique_ptr比原始指针(链接:Godbolt)更昂贵。为了稍微赶上汇编输出的原始指针成本,他必须添加noexclude、rvalue引用并使用std::move。嗯,在铁锈中,上面的工作不需要额外的努力就可以完成。让我们比较两个代码片段及其汇编输出。我不得不对外部Rust和Rust示例中的不安全进行了一些额外的调整,以防止编译器内联调用(link:Godbolt):

用更少的努力,Rust生成更少的汇编代码。而且您不需要通过使用noExcept、rvalue引用和std::move向编译器提供任何线索。当你比较语言时,你应该使用足够的基准。你不能随便举一个你喜欢的例子,然后用它来证明一种语言比另一种慢。

2019年12月,Rust在基准游戏中跑赢C++。从那以后,C++在一定程度上迎头赶上了。但是,只要您继续使用合成基准,这些语言就会一直领先于其他语言。相反,我想看看适当的基准。

如果我们拿一个大型桌面C++应用程序,并尝试用Rust重写它,我们会意识到我们的大型C++应用程序使用的是第三方库。而且很多用C编写的第三方库都有C头。您可以在C++中借用和使用这些头文件,如果可能的话,将它们包装到更安全的构造中。但在Rust中,您必须重写所有这些头文件,或者让某些软件从原始的C文件头文件中生成它们。

在这里,Anton将两个不同的问题归结在一起:C函数的声明和它们的后续使用。

实际上,在Rust中声明C函数需要您手动声明或自动生成它们-因为这是两种不同的编程语言。您可以在我关于星际争霸机器人(RU)的文章中阅读更多关于这方面的内容,或者查看演示如何生成这些包装器的示例。

幸运的是,Rust有一个名为Cargo的包管理器,它允许您一次生成声明并与世界共享。正如您可以猜到的那样,人们不仅共享原始声明,还共享安全和惯用的包装器。截至今年,即2020年,包裹登记处crates.io包含约4万箱。

至于使用C库本身,它实际上在您的配置中只占一行:

考虑到版本依赖关系,整个编译和链接工作将由货物自动完成。关于Flate2示例的有趣之处在于,当这个板条箱只出现时,它使用了用C编写的C库Miniz,但是后来社区用Rust重写了C部分。这使得平板2的速度更快。

所有生锈检查都在不安全的块中关闭;它不检查那些块中的任何东西,完全依赖于您是否编写了正确的代码。

这是将C库集成到Rust代码中问题的继续。

我很抱歉这么说,但认为在不安全模式下禁用所有检查是一种典型的误解,因为铁锈文档清楚地表明,不安全模式允许您:

对禁用所有铁锈检查只字不提。如果您有终生错误,简单地添加不安全不能帮助您的代码编译。在该块中,编译器不断检查类型、跟踪变量生存期、检查线程安全性等等。有关更多详细信息,请参阅文章您可以在Rust中关闭借阅检查器。

你不应该把不安全当做做你喜欢做的事的一种方式。这是对编译器的一个提示,您负责编译器本身无法检查的一组特定的不变量。以原始指针取消引用为例。您和我都知道,C';的malloc返回NULL或指向分配的未初始化内存块的指针,但是Rust编译器对此语义一无所知。这就是为什么,当使用由malloc返回的原始指针时,您必须告诉编译器,我知道我在做什么。我已经检查过这个-它不是空的;内存与此数据类型正确对齐。";您对不安全块中的那个指针负责。

在过去的一个月里,我在C++程序中遇到的十个错误中,有三个是由对C方法的错误处理引起的:忘记释放内存、传递错误的参数、在没有事先进行空检查的情况下传递空指针。确切地说,使用C代码有很多问题。而铁锈在这方面对你一点帮助都没有。那可不好。据说Ruust要安全得多,但是一旦您开始使用第三方库,您就必须像使用C++一样小心行事。

根据微软的统计,70%的漏洞是由于内存安全问题和其他错误类型造成的,而Rust实际上在编译时就阻止了这些问题。从物理上讲,您不能在铁锈的安全子集中犯这些错误。

另一方面,还有不安全的子集,它允许您取消引用原始指针,调用C函数.。并做其他不安全的事情,如果使用不当,可能会破坏您的程序。嗯,这正是Rust成为系统编程语言的原因。

在这一点上,您可能会发现自己在想,在Rust中必须像在C++中一样确保C函数调用的安全并不会使Rust变得更好。但是,Rust的独特之处在于能够将安全代码与潜在的不安全代码分开,并随后对后者进行封装。如果您不能保证当前级别的语义正确,则需要将不安全委托给调用代码。

Slice::GET_UNCHECKED是一个标准的不安全函数,它按索引接收元素,而不检查越界错误。由于我们也不检查函数get_elem_by_index中的索引并按原样传递它,因此我们的函数可能存在错误,任何对它的访问都要求我们将其显式指定为不安全(link:playround):

如果您传递一个越界的索引,您将访问未初始化的内存,不安全块是唯一可以这样做的地方。

这个安全版本永远不会破坏内存,无论您传递给它什么参数。让我们明确这一点-我根本不鼓励您在Rust中编写这样的代码(改为使用Slice::Get函数);我只是向您展示如何从Rust的不安全子集移动到仍然能够保证安全的安全子集。我们可以使用类似的C函数,而不是unchecked_get_elem_by_index。

多亏了跨语言的LTO,C函数的调用可以完全自由:

我将启用了编译器标志的项目上传到GitHub。得到的汇编输出与用纯C编写的代码(link:godbolt)相同,但保证与用Rust编写的代码一样安全。

假设我们有一种很棒的编程语言,叫做X。它是一种经过数学验证的编程语言。如果我们用这种X语言编写的应用程序恰好是构建的,那就意味着已经从数学上证明了我们的应用程序中没有任何错误。听起来确实不错。但是有一个问题。我们使用C库,当我们在X语言中使用它们时,我们所有的数学证明显然都失败了。

2018年证明了Rust的类型体系、借款机制、所有权机制、生存期机制、并发机制的正确性。给定一个程序,除了某些只在语义上(但不是在语法上)类型良好的组件之外,它在语法上是类型良好的,基本定理告诉我们整个程序在语义上是类型良好的。

这意味着链接和使用包含不安全但提供正确和安全的包装器的箱子(库)不会使您的代码不安全。

作为该模型的一个实际应用,其作者证明了标准库的一些原语的正确性,包括Mutex、卢旺达Lock和Thread::Spawn,所有这些原语都使用C函数。因此,您不可能在Rust;中没有同步原语的线程之间意外地共享变量,如果您使用标准库中的Mutex,即使变量的实现依赖于C函数,变量也将始终被正确访问。这不是很棒吗?当然是这样。

公正地讨论一种编程语言相对于另一种编程语言的相对优势是困难的,特别是当您强烈喜欢一种语言而不喜欢另一种语言时。看到另一个C++杀手的先知在对C++了解不多的情况下发表强有力的声明,并意外地受到抨击,这是很常见的事情。

但是,我期望公认的专家提供的是加权观察,即至少不包含严重的事实错误。

我们允许您使用PVS-Studio检查您的项目代码。只需在项目中发现一个bug,就可以比十几篇文章更好地向您展示静态代码分析方法的好处。

Goto PVS-Studio;