真正的C++杀手,并不是Rust!

清远课程 2024-05-01 10:18:27

哪些技术比 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%?

0 阅读:0