paint-brush
真正的 C++ 杀手(不是你,Rust)经过@oleksandrkaleniuk
50,731 讀數
50,731 讀數

真正的 C++ 杀手(不是你,Rust)

经过 Oleksandr Kaleniuk17m2023/02/14
Read on Terminal Reader

太長; 讀書

用糟糕的程序员编写好的代码是二十世纪的问题。现在我们需要更好的代码,但要由优秀的程序员编写,这是当前的 C++ 杀手都没有解决的任务。真正的革命超越了编译器。
featured image - 真正的 C++ 杀手(不是你,Rust)
Oleksandr Kaleniuk HackerNoon profile picture


你好!我是 Oleksandr Kaleniuk,我是 C++ 狂热者。我用 C++ 编写了 17 年,在这 17 年里,我一直在努力摆脱这种毁灭性的瘾。


这一切都始于 2005 年的 3D 空间模拟器引擎。该引擎拥有 C++ 在 2005 年拥有的一切。三星级指针、八层依赖关系和无处不在的 C 风格宏。也有装配位。迭代器 Stepanov 风格和元代码 Alexandrescu 风格。代码拥有一切。当然,最重要的问题的答案除外:为什么?


不一会儿,连这个问题都得到了回答。只是不是“为什么”,而是“为什么”。事实证明,这个引擎已经由 5 个不同的团队编写了大约 8 年。每个团队都将他们最喜欢的时尚带到项目中,将旧代码包装到新样式的包装器中,同时只添加大约 10-20 个微卡马克的功能。


起初,我老老实实地试图理解每一件小事。那不是一次令人满意的经历,一点也不,在某个时候,我放弃了。我仍在关闭任务并修复错误。不能说我的工作效率很高,但足以不被解雇。但后来我的老板问我:“你想把一些着色器代码从 Assembly 重写到 GLSG 吗?”我以为上帝知道这个 GLSL 是什么样子,但它不可能比 C++ 更糟糕,所以我同意了。并没有更糟。


而这种成为了一种模式。我仍然主要用 C++ 编写,但每次有人问我“你想做那些非 C++ 的事情吗?”我是“当然!”我确实做了那件事,不管它是什么。我用 C89、MASM32、C#、PHP、Delphi、ActionScript、JavaScript、Erlang、Python、Haskell、D、Rust,甚至是极其糟糕的 InstallShield 脚本语言编写。我用 VisualBasic、bash 和一些我什至不能合法谈论的专有语言编写。我什至不小心自己做了一个。我做了一个简单的 lisp 风格的解释器来帮助游戏设计师自动化资源加载,然后去度假了。当我回来时,他们正在用这个解释器编写整个游戏场景,所以我们必须支持它,至少到项目结束为止。


所以在过去的 17 年里,老实说,我一直在尝试退出 C++,但每次,在我尝试了一个闪亮的新东西之后,我又回来了。尽管如此,我确实认为用 C++ 编写是一个坏习惯。它是不安全的,不像它想象的那么有效,而且它在与制作软件无关的事情上浪费了程序员的大量心智能力。你知道 MSVC uint16_t(50000) + uin16_t(50000) == -1794967296 ?你知道为什么吗?是的,我就是这么想的。


我相信,阻止年轻一代以 C++ 为职业是长期 C++ 程序员的道德责任,就像不能戒烟的酗酒者警告年轻人危险是道德责任一样。


但是为什么我不能戒掉呢?怎么了?问题是,在现代世界中,没有一种语言,尤其是所谓的“C++ 杀手”,比 C++ 有任何真正的优势。所有这些新语言都主要集中在为了他们自己的利益而束缚程序员。这很好,除了用糟糕的程序员编写好的代码是 20 世纪的一个问题,当时晶体管每 18 个月增长一倍,程序员的人数每 5 年增长一倍。


我们生活在 2023 年。世界上有经验的程序员比历史上任何时候都多。我们现在比以往任何时候都更需要高效的软件。


二十世纪的事情要简单得多。你有一个想法,你将它包装成一些 UI,并将其作为桌面产品出售。慢吗?谁在乎!无论如何,在 18 个月内,台式机的速度将提高 2 倍。重要的是进入市场,开始销售功能,最好没有错误。在那种环境下,当然,如果编译器可以防止程序员犯错误——那就太好了!因为错误不会带来现金,而且无论他们添加功能还是错误,您都必须向程序员付款。


现在情况不同了。你有一个想法,你将它包装在一个 Docker 容器中并在云中运行它。现在,如果运行您软件的人解决了他们的问题,您就可以从他们那里获得收入。即使它做了一件事但做对了,你也会得到报酬。你不必为了销售它的新版本而用虚构的功能填充你的产品。另一方面,为你的代码低效买单的人现在是你自己。每个次优例程都会显示在您的 AWS 账单中。


因此,在新环境下,您现在需要的功能更少,但无论您拥有什么,都需要更好的性能。


突然间,事实证明所有“C++ 杀手”,甚至是我全心全意喜爱和尊重的 Rust、Julia 和 D,都没有解决 21 世纪的问题。他们还停留在XX。它们确实可以帮助您以更少的错误编写更多的功能,但是当您需要从租用的硬件中挤出最后的失败时,它们并没有多大帮助。


它们只是不会给您带来优于 C++ 的竞争优势。或者,就此而言,甚至是彼此之上。其中大多数,例如 Rust、Julia 和 Cland 甚至共享相同的后端。如果大家共享同一辆车,就无法赢得赛车比赛。


那么,与 C++ 或一般而言,与所有传统的提前编译器相比,哪些技术确实给您带来了竞争优势?好问题。很高兴你问。


C++ 杀手一号。螺旋

但在我们使用 Spiral 本身之前,让我们检查一下您的直觉如何运作。您认为哪个更快:标准 C++ 正弦函数还是正弦的 4 项多项式模型?


 auto y = std::sin(x); // vs. y = -0.000182690409228785*x*x*x*x*x*x*x +0.00830460224186793*x*x*x*x*x -0.166651012143690*x*x*x +x;


下一个问题。什么工作更快,使用带短路的逻辑运算,或者欺骗编译器来避免它并批量计算逻辑表达式?


 if (xs[i] == 1 && xs[i+1] == 1 && xs[i+2] == 1 && xs[i+3] == 1) // xs are bools stored as ints // vs. inline int sq(int x) { return x*x; } if(sq(xs[i] - 1) + sq(xs[i+1] - 1) + sq(xs[i+2] - 1) + sq(xs[i+3] - 1) == 0)


还有一个。什么排序三元组更快:交换排序还是索引排序?


 if(s[0] > s[1]) swap(s[0], s[1]); if(s[1] > s[2]) swap(s[1], s[2]); if(s[0] > s[1]) swap(s[0], s[1]); // vs. const auto a = s[0]; const auto b = s[1]; const auto c = s[2]; s[int(a > b) + int(a > c)] = a; s[int(b >= a) + int(b > c)] = b; s[int(c >= a) + int(c >= b)] = c;


如果你果断地回答了所有问题,甚至没有思考或谷歌搜索,那么你的直觉就失败了。你没有看到陷阱。没有上下文,这些问题都没有明确的答案。


代码针对哪个 CPU 或 GPU?哪个编译器应该构建代码?哪些编译器优化开启,哪些关闭?只有当您知道所有这些时,您才能开始预测,或者更好的是,测量每个特定解决方案的运行时间。


  1. 如果使用带有-O2 -march=native 的clang 11 构建并在Intel Core i7-9700F上运行,多项式模型比标准正弦模型快 3 倍。但是,如果使用带--use-fast-math的 nvcc 和 GPU 即GeForce GTX 1050 Ti Mobile构建,标准正弦比模型快 10 倍。


  2. 将短路逻辑换成向量化算术在 i7 上也很有意义。使代码片段的工作速度提高一倍。但在具有相同 clang 和 -O2 的 ARMv7 上,标准逻辑比微优化快 25%


  3. 索引排序与交换排序相比,索引排序在 Intel 上快 3 倍,交换排序在 GeForce 上快 3 倍


因此,我们都非常喜欢的亲爱的微优化可能会将我们的代码加速 3 倍,并将其速度降低 90%。这完全取决于上下文。如果编译器可以为我们选择最好的替代方案,那将是多么美妙啊,例如,当我们切换构建目标时,索引排序会奇迹般地变成交换排序。但它不能。


  1. 即使我们允许编译器将正弦重新实现为多项式模型,以牺牲精度换取速度,它仍然不知道我们的目标精度。在C++中,我们不能说“允许这个函数有那个错误”。我们所拥有的只是像“--use-fast-math”这样的编译器标志,并且只在翻译单元的范围内。


  2. 在第二个示例中,编译器不知道我们的值被限制为 0 或 1,并且不可能提出我们可以提出的优化。我们可能可以通过使用适当的 bool 类型来暗示它,但那将是一个完全不同的问题。


  3. 在第三个例子中,代码片段有很大的不同,可以被识别为同义词。我们把代码详细化了太多。如果它只是 std::sort,这就已经给了编译器更多选择算法的自由。但它既不会选择索引排序也不会选择交换排序,因为它们在大型数组上效率低下,并且 std::sort 适用于通用的可迭代容器。


这就是我们到达Spiral 的方式。这是卡内基梅隆大学和 Eidgenössische Technische Hochschule Zürich 的联合项目。 TL&DR:信号处理专家厌倦了为每个新硬件手动重写他们最喜欢的算法,并编写了一个程序来为他们完成这项工作。该程序采用算法的高级描述和硬件架构的详细描述,并优化代码,直到它为指定的硬件实现最有效的算法。


Fortran 和类似语言之间的一个重要区别是,Spiral 真正解决了数学意义上的优化问题。它将运行时间定义为目标函数,并在受硬件架构限制的实现变体的因子空间中寻找其全局最优值。这是编译器实际上从未做过的事情。


编译器不会寻找真正的最佳。它根据程序员传授的启发式方法优化代码。本质上,编译器不是作为寻找最佳解决方案的机器工作,而是作为汇编程序员工作。一个好的编译器就像一个好的汇编程序员一样工作,但仅此而已。




Spiral 是一个研究项目。它的范围和预算有限。但它显示的结果已经令人印象深刻。在快速傅里叶变换上,他们的解决方案明显优于 MKL 和 FFTW 实现。他们的代码快约 2 倍。即使在英特尔。


只是为了突出成就的规模,MKL 是英特尔自己的数学核心库,因此由最了解如何使用其硬件的人开发。 WWTF 又名“西方最快的傅里叶变换”是一个高度专业化的库,来自最了解算法的人。他们都是各自领域的冠军,而 Spiral 两次击败他们的事实令人震惊。


当 Spiral 使用的优化技术最终确定并商业化时,不仅 C++,而且 Rust、Julia 甚至 Fortran 都将面临前所未有的竞争。如果用高级算法描述语言编写能让你的代码快 2 倍,为什么还有人会用 C++ 编写呢?


C++ 杀手 2. Numba

最好的编程语言是您已经熟知的语言。几十年来,大多数程序员最熟悉的语言一直是 C。它还在 TIOBE 指数中领先于其他 C 语言,紧紧占据前 10 名。然而,就在两年前,发生了一些闻所未闻的事情。 C把它的第一位让给了其他东西。


“其他东西”似乎是 Python。一种在 90 年代没有人认真对待的语言,因为它是另一种我们已经拥有很多的脚本语言。



有人会说:“呸,Python 很慢”,看起来像个傻瓜,因为这是术语上的废话。就像手风琴或煎锅一样,一种语言根本无法快或慢。就像手风琴的速度取决于演奏者,一门语言的“速度”取决于其编译器的速度。


“但是 Python 不是一种编译语言”有人可能会继续说下去,但又一次失败了。有很多 Python 编译器,其中最有前途的是 Python 脚本。让我解释。


我曾经有一个项目。一个 3D 打印模拟,最初是用 Python 编写的,然后用 C++ 重写以“提高性能”,然后移植到 GPU,所有这些都是在我进来之前完成的。然后我花了几个月的时间将构建移植到 Linux,优化 GPU 代码Tesla M60 因为它当时是 AWS 中最便宜的,并且验证了 C++/CU 代码中的所有更改以与 Python 中的原始代码一致。所以我做了所有的事情,除了我通常擅长的事情,即设计几何算法。


当我最终一切正常时,一位来自不来梅的兼职学生打电话问我:“所以你擅长异构的东西,你能帮我在 GPU 上运行一个算法吗?”当然!我向他介绍了 CUDA、CMake、Linux 构建、测试和优化;大概聊了一个小时。他非常有礼貌地听完了所有这些,但最后说:“这一切都很有趣,但我有一个非常具体的问题。所以我有一个函数,我在它的定义之前写了@cuda.jit,Python 说了一些关于数组的事情并且不编译内核。你知道这里可能有什么问题吗?”


我不知道。他自己在一天内就弄明白了。显然,Numba 不适用于本机 Python 列表,它只接受 NumPy 数组中的数据。所以他弄明白了,并在 GPU 上运行了他的算法。在 Python 中。他没有我花了几个月的时间解决的问题。你想在 Linux 上使用它吗?没问题,只需在 Linux 上运行即可。你想让它和 Python 代码保持一致吗?没问题,它是 Python 代码。您想针对目标平台进行优化吗?又不是问题。 Numba 将针对您运行代码的平台优化代码,因为它不会提前编译,而是在已部署时按需编译。


这不是很棒吗?好吧,不。反正不适合我。我花了几个月的时间使用 C++ 解决 Numba 中从未出现过的问题,来自不来梅的一名兼职人员在几天内做出了同样的事情。如果不是他第一次使用 Numba,可能需要几个小时。那么这个 Numba 是什么?这是什么法术?


没有魔法。 Python 装饰器为您将每一段代码转换为它的抽象语法树,这样您就可以随心所欲地使用它。 Numba是一个 Python 库,它希望使用它拥有的任何后端以及它支持的任何平台来编译抽象语法树。如果你想编译你的 Python 代码以大规模并行方式在 CPU 内核上运行——只需告诉 Numba 编译它。如果你想在 GPU 上运行某些东西,同样,你应该只询问.


 @cuda.jit def matmul(A, B, C): """Perform square matrix multiplication of C = A * B.""" i, j = cuda.grid(2) if i < C.shape[0] and j < C.shape[1]: tmp = 0. for k in range(A.shape[1]): tmp += A[i, k] * B[k, j] C[i, j] = tmp


Numba 是让 C++ 过时的 Python 编译器之一。然而,从理论上讲,它并不比 C++ 好多少,因为它使用相同的后端。它使用 CUDA 进行 GPU 编程,使用 LLVM 进行 CPU 编程。实际上,由于不需要为每个新架构提前重建,Numba 解决方案可以更好地适应每个新硬件及其可用的优化。


当然,如果像 Spiral 那样具有明显的性能优势会更好。但 Spiral 更像是一个研究项目,它可能会扼杀 C++,但只是最终,而且只有在幸运的情况下。带有 Python 的 Numba 现在实时地扼杀了 C++。因为如果你能用Python写,有C++的性能,你为什么要用C++写?


C++ 杀手 3. ForwardCom

让我们玩另一个游戏。我会给你三段代码,你会猜出其中哪一段,或者更多,是用汇编写的。他们来了:


 invoke RegisterClassEx, addr wc ; register our window class invoke CreateWindowEx,NULL, ADDR ClassName, ADDR AppName,\ WS_OVERLAPPEDWINDOW,\ CW_USEDEFAULT, CW_USEDEFAULT,\ CW_USEDEFAULT, CW_USEDEFAULT,\ NULL, NULL, hInst, NULL mov hwnd,eax invoke ShowWindow, hwnd,CmdShow ; display our window on desktop invoke UpdateWindow, hwnd ; refresh the client area .while TRUE ; Enter message loop invoke GetMessage, ADDR msg,NULL,0,0 .break .if (!eax) invoke TranslateMessage, ADDR msg invoke DispatchMessage, ADDR msg .endw


 (module (func $add (param $lhs i32) (param $rhs i32) (result i32) get_local $lhs get_local $rhs i32.add) (export "add" (func $add)))


 v0 = my_vector // we want the horizontal sum of this int64 r0 = get_len ( v0 ) int64 r0 = round_u2 ( r0 ) float v0 = set_len ( r0 , v0 ) while ( uint64 r0 > 4) { uint64 r0 >>= 1 float v1 = shift_reduce ( r0 , v0 ) float v0 = v1 + v0 }


那么哪一个或多个正在组装中?如果你认为这三个,恭喜你!你的直觉已经变得更好了!


第一个在 MASM32 中。它是一个带有“if”和“while”的宏汇编器,人们在其中编写本机 Windows 应用程序。没错,不是“习惯于编写”,而是“至今仍在编写”。 Microsoft 热心地保护 Windows 与 Win32 API 的向后兼容性,因此所有编写的 MASM32 程序在现代 PC 上也能很好地工作。


具有讽刺意味的是,C 的发明是为了使 UNIX 从 PDP-7 到 PDP-11 的转换更容易。它被设计成一个便携式汇编器,能够在 70 年代硬件架构的寒武纪大爆炸中幸存下来。但是在二十一世纪,硬件架构的发展如此缓慢,我 20 年前在 MASM32 中编写的程序今天可以完美地组装和运行,但我没有信心我去年使用 CMake 3.21 构建的 C++ 应用程序今天可以使用 CMake 构建3.25.


第二段代码是WebAssembly。它甚至不是一个宏汇编程序,它没有“if”和“while”,它更像是一种供浏览器使用的人类可读的机器代码。或者其他浏览器。从概念上讲,任何浏览器。


Web Assembly 代码完全不依赖于您的硬件架构。它所服务的机器是抽象的、虚拟的、通用的,随便你怎么称呼它。如果你能阅读这篇文章,那么你的物理机器上已经有了一个。


但最有趣的一段代码是第三段。这是 ForwardCom – 汇编器 Agner Fog,C++ 和汇编优化手册的著名作者,提议。与 Web Assembly 一样,该提案涵盖的与其说是汇编程序,不如说是通用指令集,旨在实现向后兼容性和向前兼容性。由此得名。 ForwardCom的全称是“一种开放的前向兼容指令集架构”。换句话说,与其说是集会提案,不如说是和平条约提案。


我们知道所有最常见的架构系列:x64、ARM 和 RISC-V 都有不同的指令集。但没有人知道保持这种状态的充分理由。所有的现代处理器,除了最简单的处理器,都运行的不是你输入的代码,而是它们将你的输入翻译成的微代码。因此,不仅 M1 具有面向 Intel 的向后兼容层,每个处理器本质上都具有面向其所有早期版本的向后兼容层。


那么,是什么阻止了架构设计人员在类似的层上达成一致,而是为了向前兼容呢?除了处于直接竞争中的公司相互矛盾的野心之外,什么都没有。但是,如果处理器制造商在某个时候愿意采用通用指令集,而不是为每个其他竞争对手实施新的兼容层,则 ForwardCom 将使汇编编程重新成为主流。这个向前兼容层将治愈那里每个汇编程序员更严重的神经症:“如果我为这个特定的架构编写千载难逢的代码,而这个特定的架构将在一年内过时呢?”


有了向前兼容层,它永远不会让自己过时。这才是重点。


汇编编程也受到一个神话的阻碍,即用汇编编写很难,因此不切实际。 Fog 的提议也解决了这个问题。如果人们认为用汇编编写很难,而用 C 编写则不难,那么,让我们让汇编器看起来像 C。这不是问题。没有充分的理由让现代汇编语言看起来与其祖父在 50 年代的样子完全一样。


您刚刚亲眼看到了三个装配样品。它们看起来都不像“传统”组件,也不应该是。


因此,ForwardCom 是您可以在其中编写永不过时的最佳代码的程序集,并且不会让您学习“传统”程序集。出于所有实际考虑,它是未来的 C。不是 C++。

那么 С++ 什么时候会消亡呢?

我们生活在一个后现代世界。除了人,什么都不会死。正如 Latin 从未真正消亡,就像 COBOL、Algol 68 和 Ada 一样,C++ 注定永远处于生与死之间。 C++ 永远不会消亡,它只会被更新更强大的技术赶出主流。


好吧,不是“将被推动”而是“被推动”。我以 C++ 程序员的身份开始了我目前的工作,今天我的工作从 Python 开始。我写方程式,SymPy 帮我解决它们,然后将解决方案翻译成 C++。然后,我将这段代码粘贴到 C++ 库中,甚至懒得稍微格式化一下,因为 clang-tidy 无论如何都会为我做这件事。静态分析器将检查我没有弄乱命名空间,而动态分析器将检查内存泄漏。 CI/CD 将负责跨平台编译。探查器将帮助我了解我的代码实际如何工作,以及反汇编程序——为什么。


如果我将 C++ 换成“非 C++”,我 80% 的工作将保持完全相同。 C++ 与我所做的大部分工作完全无关。这是否意味着对我来说 C++ 已经 80% 死了?



通过稳定扩散显影的铅图像。