真正的 C++ 杀手不是 Rust

程序员咋不秃头2024-04-03 18:21:07  104

“C++ 已经死了 80%?”本文作者已经使用 C++ 18 年了,他在体验了数十门编程语言后,他指出,尽管 C++ 在过去几十年中一直是程序员最常用的编程语言之一,但它存在一些问题,如不安全、效率低、浪费程序员的精力等。因此,文章探讨了一些可能会取代 C++ 的语言和技术,包括 Spiral、Numba 和 ForwardCom 等,并分别对它们进行了详细的介绍。

以下为译文:

我是 C++ 粉,已经用 C++ 写了 18 年代码,而在这 18 年里,我一直在努力摆脱 C++。

一切始于 2005 年末的一个三维空间模拟引擎。该引擎具备了当时 C++ 所有的特性,三星指针、八层依赖关系,以及无处不在的 C 风格的宏。还有一些汇编代码片段,Stepanov 风格的迭代器,以及 Alexandrescu 风格的元编码。总之是应有尽有。那么,为什么呢?

因为这款引擎前后历时 8 年的时间,经手了 5 个不同的团队。每个团队都把自己喜欢的时髦技术带到了项目中,用时髦的包装方式包裹旧代码,但真正为引擎本身添加的价值却很少。

起初,我认真地尝试理解每一处小细节,但在碰了一鼻子灰之后,我放弃了。我还是老老实实完成任务,改 bug 吧。不能说我的工作效率很高,只能说很勉强,不至于被解雇。但后来我的老板问我:“你想把部分汇编代码改成 GLSG 吗?”虽然我并不了解GLSL是什么,但我觉得总不至于还不如 C++ 吧,于是我答应了。结果确实不至于还不如 C++。

后来,大部分的时间里我仍在用 C++ 写代码,但每当有人问我:“你想不想尝试一些非 C++ 的工作?”我就会说:“当然!”然后我就会去做。我写过 C89、MASM32、C#、PHP、Delphi、ActionScript、JavaScript、Erlang、Python、Haskell、D、Rust,以及令人闻风丧胆的脚本语言 InstallShield。我甚至还写过 VisualBasic、bash,以及几种不能公开谈论的专有语言。我甚至编写过自己的语言,我写了一个简单的 Lisp 风格解释器,帮助游戏设计师自动加载资源,然后去度假了。回来后发现他们用这个解释器编写了整个游戏场景,所以在接下来的一段时间里我们必须支持这个解释器。

在过去的 17 年里,我一直在努力摆脱 C++,但每次尝试过新技术后,总是会回到 C++。尽管如此,我仍然认为使用 C++ 编写程序是一个坏习惯。这门语言并不安全,效率也达不到人们的期望,而且程序员需要在与软件制作毫无关系的工作上浪费大量精力。你知道在 MSVC 中 uint16_t(50000) + uint16_t(50000) == -1794967296 吗?你知道为什么吗?你的看法与我不谋而合。

我认为,作为一名长期使用 C++ 的程序员,我有责任劝诫年轻一代程序员不要将 C++ 作为自己的专攻语言,就像有不良嗜好的人有责任劝诫不要重蹈覆辙。

那么,为什么我无法放弃 C++ 呢?问题出在哪里?问题在于,所有的编程语言,尤其是那些所谓的“C++ 杀手”,真正带来的优势都未能超越 C++。这些新语言大多会从一定程度上约束程序员。这本身没什么问题,毕竟当年晶体管密度每 18 个月翻一番,而程序员的数量每 5 年才翻一番,糟糕的程序员写不出优秀的代码也并不是什么大问题。

如今,我们生活在 21 世纪。经验丰富的程序员数量超过了历史任何时期,而且我们更需要高效的软件。

上个世纪,编写软件很简单。你有一个想法,然后将其包装成 UI,再作为桌面系统软件产品出售就可以了。运行速度太慢?没人在乎!18 个月内,台式机的速度就会翻倍。重要的是进入市场,打开销路,而且还没有 bug。当然,如果编译器能防止程序员犯错就更好了,因为 bug 不但不会产生收益,而且你还要付钱给程序员改 bug。

而如今情况大不相同了。你有一个想法,然后将其包装到 Docke 容器中,并在云中运行。如今想获取收入,你的软件就必须为用户解决问题。即使一款产品只做一件事,但只要做的正确,就能获得报酬。你不必为了销售新版本的产品而不断扩充功能。相反,如果你的代码发挥不了真正的作用,买单的就是你自己。云账单就能真实地反映出你的程序是否真的起作用。

因此,在新的环境下,你需要的功能更少,但所有的功能都需要更出色的性能。

在这个前提下你就会发现,所有的“C++ 杀手”,甚至是我由衷喜欢和尊敬的 Rust、Julia 和 D,也没有解决 21 世纪的问题。它们仍然停留在上个世纪。虽然这些语言可以帮助你编写更多功能,而且 bug 更少,但当你需要从租用的硬件中压榨出最后一点 FLOPS 时,它们就没有太大用处了。

因此,这些语言只不过是比 C++ 更具竞争优势,或者说彼此之间可以竞争。但大多数编程语言,例如 Rust、Julia 和 Cland,甚至共享同一个后端。所有赛车手都坐在同一辆车上,何谈谁能赢得比赛呢?

那么,究竟哪些技术比 C++ 或者传统的预先编译器更有优势呢?

C++的头号杀手:Spiral

在讨论 Spiral 之前,让我先来考考你。你觉得以下哪个版本的代码运行速度更快?版本1:标准的 C++ 正弦函数;版本2:由4个多项式模型组成的正弦函数?

下一个问题。以下哪个版本的代码运行速度更快?版本1:使用短路逻辑运算;版本2:将逻辑表达式转换为算术表达式?

第三个问题,以下哪个版本的三元组排序更快?版本1:带有分支的交换排序;版本2:无分支的索引排序?

如果你果断地回答了以上所有问题,甚至没有思考或上网搜索,那么只能说你被自己的直觉骗了。你没有发现陷阱吗?在没有上下文的情况下,这些问题都没有确定的答案。

如果使用 clang 11 和 -O2 -march=native 构建,在英特尔Core i7-9700F 上运行,多项式模型比标准正弦快 3 倍。但如果使用 NVCC 和 --use-fast-math 构建,在GeForce GTX 1050 Ti Mobile 上运行,标准正弦比多项式模型快10 倍。

在 i7 上,如果将短路逻辑替换为向量化算术,可以将代码的运行速度提高一倍。但在 ARMv7 上,使用 clang 和-O2,标准逻辑比微优化快 25%。

对于索引排序与交换排序,在英特尔上,索引排序比交换排序快 3 倍;而在 GeForce 上,交换排序比索引排序快 3 倍。

因此,我们喜爱的微优化都有可能将代码的运行提升3倍,也有可能导致速度下降90%。这完全取决于上下文。如果编译器能为我们选择最佳替代方案,那该多好,例如,当我们切换构建目标时,索引排序会神奇地变成交换排序。但可惜编译器做不到。

即使我们允许编译器将正弦函数换成多项式模型,用牺牲精度的代价换取速度,它也不清楚我们的目标精度。在 C++ 中,我们无法表达:“此函数允许有误差”。我们只有--use-fast-math之类的编译器标志,而且只在翻译单元的范围内。

在第二个示例中,编译器不知道我们的值仅限于 0 或 1,而且也不可能提出可以实施的优化。虽然我们可以通过布尔类型来暗示,但这又是另一个问题了。

在第三个示例中,两段代码完全不同,编译器无法将二者视为等效代码。代码描写了太多细节。如果只有 std::sort,就可以给编译器更多自由选择算法的空间。但它不会选择索引排序或交换排序,因为这两种算法处理大型数组的效率都很低,而 std::sort 适合通用可迭代容器。

此处就不得不提到 Spiral 了。该语言是卡内基梅隆大学和苏黎世联邦理工学院的联合项目。简单来说,信号处理专家厌倦了每出现一种新硬件就需要手动重写他们喜欢的算法,因此编写了一个可自动完成这项工作的程序。该程序接受算法的高级描述和硬件架构的详细描述,并优化代码,直到在指定的硬件上实现最高效的算法。

与 Fortran 等语言不同,Spiral 真正解决了数学意义上的优化问题。它将运行时定义为目标函数,并在受硬件架构限制的可变因素空间内寻找全局最优实现。编译器永远无法真正实现这种优化。

编译器不会寻找真正的最优解。它只不过是根据程序员所教的启发式规则来优化代码。实质上,编译器并不是一个寻找最优解的机器,更像一个汇编程序员。一个好的编译器就像一个好的汇编程序员,仅此而已。

Spiral是一个研究项目,范围和预算都很有限。但最后展现的结果却很惊人。在快速傅里叶变换中,他们的解决方案明显优于 MKL 和 FFTW 的实现,他们的代码速度约快了 2 倍,即使在英特尔上也是如此。

为了突显如此宏大的成就,需要说明一下,MKL 是英特尔自己的数学内核库(Math Kernel Library,简称MKL),因此他们非常了解如何充分利用自家的硬件。而WWTF(Fastest Fourier Transform in the West,西部最快傅里叶变换)是一种高度专业化的库,由最了解该算法的人编写。二者都是各自领域的冠军,而 Spiral 的速度能够达到二者两倍,这实在太不可思议了。

等到 Spiral 使用的优化技术最终成熟并商业化,不仅仅是 C++,包括 Rust、Julia,甚至 Fortran 都将面临前所未有的竞争压力。既然能使用高级算法描述语言编写2倍速的代码,谁还会使用C++呢?

C++ 杀手之二:Numba

相信你很熟悉这门优秀的编程语言。几十年来,大多数程序员来说最熟悉的语言一直是 C。在 TIOBE 指数中,C语言一直名列第一,其他类似 C 的语言占据了前十名。然而,两年前,一件前所未闻的事情发生了,C 语言第一名的地位不保。

取而代之的语言是Python。90年代,没有人看好Python,因为它不过是众多脚本语言中的一个。

有人会说:“Python很慢”,但这种说法很荒谬,就像说手风琴或平底锅很慢一样,语言本身没有快慢之分。就像手风琴的速度取决于演奏者一样,语言的快慢取决于编译器的速度。

可能还会有人说:“Python不是一种编译语言”,这个说法也不严谨。Python 编译器有很多,其中一个最被看好的编译器也算是Python脚本。我来解释一下。

我曾经有一个项目,是一个3D打印模拟,最初是用Python编写的,后来“为了性能”改用C++重写,后来又移植到 GPU 上,当然这些都是在我进入项目之前发生的事儿。后来,我花了几个月的时间将构建迁移到 Linux,优化了 Tesla M60 的 GPU 代码,因为这是当时AWS中最便宜的GPU。之后,我又在 C++/CU 代码中验证了所有变更,以便与原来的Python代码相结合。除了设计几何算法之外,所有的工作都是由我完成的。

在一切正常运行后,Bremen 的一名兼职学生打电话给我问道:“听说你很擅长使用多种技术,能帮我在 GPU 上运行一个算法吗?”“当然可以!”我给他讲了CUDA、CMake、Linux 构建、测试以及优化等等,大约花了一个小时。他很有礼貌地听完了我的介绍,最后说:“很有意思,但我想问一个非常具体的问题。我有一个函数,我在函数的定义前面加了@cuda.jit,Python就无法编译内核了,还提示了一些关于数组的错误。你知道这里面有什么问题吗?”

我不知道。后来,他花了一天时间自己搞清楚了。原因是,Numba 无法处理原生的Python列表,只接受 NumPy 数组中的数据。他找到了问题所在,并在 GPU 上运行了算法。使用的是Python。他没有遇到我花费了几个月心思解决的任何“问题”。想在 Linux 上运行代码?没问题,直接在Linux运行即可。想针对目标平台优化代码?也不是问题。Numba 会替你优化在平台上运行的代码,因为它不会预先编译代码,而是在部署时按需编译。

很厉害,对不对?然而,对我来说并不是。我花费了几个月的时间,使用C++解决 Numba 中不会出现的问题,而那位Bremen的兼职学生完成相同的工作只花费了几天的时间。如果不是因为那是他第一次使用Numba,可能只需要几个小时。说到底,Numba是什么?它是一种什么样的魔法?

没有魔法。Python 的装饰器将每一段代码都转换成了抽象语法树,因此你可以随意处理。Numba是一个 Python 库,可使用任何后端、为任何支持的平台编译抽象语法树。如果你想将Python 代码编译成以高度并行的方式在 CPU 核心上运行,只需告诉 Numba 编译即可。如果你希望在GPU上运行代码,同样只需提出请求即可。

Numba是一个Python编译器,可以淘汰C++。然而,从理论上来说,Numba并没有超越C++,因为二者使用的是同一个后端。Numba的GPU编程使用了CUDA,CPU编程使用了LLVM。实际上,由于它不需要针对每种新的架构提前重建,因此能够更好地适应每种新硬件及其潜在的优化。

当然,如果Numba能像Spiral那样具有显著的性能优势会更好。但Spiral更像是一个研究项目,最终可能会淘汰C++,但前提是足够幸运才行。Numba与Python的结合可以立即判C++死刑。如果可以使用Python编程,而且能拥有C++的性能,谁还会写C++代码呢?

C++ 杀手之三:ForwardCom

下面,我们再玩一个游戏。我给你三段代码,你猜猜哪一段(也有可能是多段)是用汇编语言编写的。

第一段代码:

第二段代码:

第三段代码:

如果你猜到这三个例子都是汇编,那么恭喜你!

第一个例子是用 MASM32 编写的。这是一个带有“if”和“while”的宏汇编器,用于编写原生Windows 应用程序。注意,不是以前有人这么写,而是至今仍在采用这种写法。微软一直在积极维护Windows 与 Win32 API 的向后兼容性,因此所有以前编写的 MASM32 程序都可以在现代 PC 上正常运行。

很讽刺的是,C 语言的发明是为了降低将 UNIX 从PDP-7 转换成 PDP-11 的难度。C语言的设计初衷就是成为一种便携式汇编语言,能够在 70 年代硬件架构的寒武纪爆发中生存下来。但在 21 世纪,硬件架构的演变如此缓慢,我在 20 年前用 MASM32 写的程序如今仍然能完美运行,但我不敢确定去年用 CMake 3.21 构建的 C++ 应用程序今时今日能否用 CMake 3.25 构建。

第二段代码是 WebAssembly,这门技术甚至不是一个宏汇编器,没有“if”和“while”,更像是人类可读的浏览器机器码。从概念上来说,可以是任何浏览器。

WebAssembly代码根本不依赖于硬件架构。它提供的机器是抽象的、虚拟的、通用的,随你怎么称呼它。如果你能阅读这段文字,说明你的物理机器上已经有一个能运行WebAssembly的硬件架构了。

最有趣的是第三段代码。这是 ForwardCom:一款由著名的 C++ 和汇编优化手册作者 Agner Fog 提出的汇编器。与 Web Assembly 一样,这不仅仅是一个汇编器,而且旨在实现向后以及向前兼容性的通用指令集。因此得名。ForwardCom 的全称是an open forward-compatible instruction set architecture(一款开放式向前兼容指令集架构)。换句话说,它不仅是一个汇编器的提议,而且也是一份和平条约提议。

我们知道最常见的计算机架构系列 x64、ARM 和 RISC-V 都有不同的指令集。但没有人知道为什么要保持这种状态。所有现代处理器,除了最简单的一些之外,运行的都不是你提供的代码,而是将你的输入转换为微码。因此,不仅M1芯片提供英特尔的向后兼容层,每个处理器本质上都为自己的早期版本提供了向后兼容层。

那么,为什么架构设计者未能就类似的向前兼容层达成统一意见呢?无外乎各个公司之间的竞争野心。但如果处理器制造商最终决定建立一个共同的指令集,而不是为每个竞争对手实现一个新的兼容层,ForwardCom就能够让汇编重回主流。这种向前兼容层可以治愈每个汇编程序员最大的心理创伤:“如今我为这个特定的架构编写一次性代码,不出一年就会被淘汰?”

有了向前兼容层,这些代码就永远不会过时。这就是关键所在。

此外,汇编编程还受到了另一种错误观念的限制,人们普遍认为汇编代码太难写,因此不实用。Fog 的提议也解决了这个问题。如果人们认为写汇编代码太难,而写 C 不难,那么我们就把汇编变成C语言。这不是问题。现代汇编语言没有必要延续50年代祖宗的模样。

上面你看到的三个汇编示例都不像“传统”的汇编,而且也不应该还是老样子。

ForwardCom是一种汇编,可用于编写永远不会过时的最佳代码,并且不需要学习“传统”的汇编。从现实的角度来看率,ForwardCom是未来的 C。不是 C++。

C++ 什么时候终消亡?

我们生活在一个后现代世界。与世长辞的不是技术,而是人。就像拉丁语从未真正消失一样,COBOL、Algol 68 和 Ada 也一样,C++ 注定要永远介于生死参半的状态。C++ 永远不会真正消失,它只会被更新更强大的新技术所取代。

严格来说,不是“将来会被取代”,而是“正在被取代”。我的职业生涯源自 C++,而如今在使用 Python 写代码。我编写方程式,SymPy 帮我求解,然后将解决方案转换为 C++。然后,我将这段代码粘贴到 C++ 库中,甚至都无需调整格式,因为 clang-tidy 会自动完成。静态分析器会检查命名空间是否混乱,动态分析器会检查内存泄漏。CI/CD 负责跨平台编译。性能分析器让我了解代码实际的运行情况,反汇编器可以解释为什么。

如果我用 C++ 之外的技术代替 C++,那么 80% 的工作不会有变化。对于我的大多数工作来说,C++ 根本无关紧要。这是否意味着,对于我来说,C++ 已经死了 80%?

转载此文是出于传递更多信息目的。若来源标注错误或侵犯了您的合法权益,请与本站联系,我们将及时更正、删除、谢谢。
https://www.414w.com/read/134591.html
0
最新回复(0)