【译文】真正的 C++ 杀手(不是你,Rust)

你好!我是 Oleksandr Kaleniuk,我是一个 C++ 爱好者。我已经用 C++ 写了 18 年,在这 18 年里,我一直在努力摆脱这种毁灭性的上瘾。

一切始于 2005 年末的一个 3D 空间模拟器引擎。这个引擎拥有 2005 年 C++ 所拥有的一切。三星级指针、八层依赖关系和随处可见的 C 风格宏。还有汇编位。斯捷潘诺夫式的迭代器和亚历山德列斯库式的元代码。代码应有尽有。当然,除了最重要问题的答案:为什么?

过了一会儿,这个问题也有了答案。但不是 “为什么”,而是 “为什么”。原来,这个引擎已经由 5 个不同的团队编写了大约 8 年。每个团队都为项目带来了自己喜欢的时尚,将旧代码包装成时尚风格的封装器,但却没有为引擎本身带来多少价值。

起初,我老老实实地努力摸索每一件小事。这并不是一个令人满意的经历,一点也不,在某些时候,我放弃了。我仍然在关闭任务,修复漏洞。不能说我的工作效率很高,只能说我的工作效率足够高,不至于被炒鱿鱼。但后来我的老板问我”你想不想把一些着色器代码从汇编改写成 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++ 的真正优势。所有这些新语言的主要目的都是为程序员好。这很好,只是用糟糕的程序员写出好代码是二十世纪的问题,当时晶体管密度每 18 个月增长 2 倍,程序员人数每 5 年增长 2 倍。

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

在二十一世纪,一切都比较简单。你有一个想法,把它包装成一些用户界面,然后作为桌面产品出售。速度慢吗?谁在乎呢?反正十八个月后,台式机的速度会提高两倍。重要的是进入市场,开始销售功能,而且最好没有错误。在这种情况下,当然,如果编译器能阻止程序员制造错误,那就太好了!因为错误不会带来现金,而且无论程序员增加了什么功能或错误,你都得付钱给他们。

现在情况不同了。你有了一个想法,将其封装在 Docker 容器中,然后在云中运行。现在,你可以从人们运行你的软件中获得收入,只要它能让他们的问题迎刃而解。即使它只做了一件事,但做对了,你也能获得收入。你不必为了卖出新版本的产品而在产品中塞满虚构的功能。另一方面,为你代码的低效付出代价的是你自己。每一个次优的例程都会显示在你的 AWS 账单上。

因此,在新形势下,您现在需要更少的功能,但同时也需要更好的性能来实现现有功能。

突然发现,所有的 “C++ 杀手”,即使是那些我衷心喜爱和尊重的,如 Rust、Julia 和 D,都没有解决二十一世纪的问题。它们仍停留在二十世纪。它们的确可以帮助你编写更多的功能和更少的错误,但当你需要从租用的硬件中榨取最后的 FLOPS 时,它们就帮不上什么忙了。

因此,与 C++ 相比,它们只是具有竞争优势。或者说,它们相互之间也没有竞争优势。它们中的大多数,例如 Rust、Julia 和 Cland 甚至共享同一个后台。如果所有赛车手都坐在同一辆车里,就没有人能赢得比赛。

那么,与 C++ 或所有传统的超前编译器相比,哪些技术能给你带来竞争优势呢?

问得好!

很高兴你这么问。

C++ 1号杀手 Spiral

在介绍 Spiral 本身之前,我们先来看看你的直觉有多灵敏。你认为标准 C++ 正弦函数和正弦的四元多项式模型哪个更快?

// 版本 1
auto y = std::sin(x);
 
// 版本 2
y = -0.000182690409228785*x*x*x*x*x*x*x
    +0.00830460224186793*x*x*x*x*x
    -0.166651012143690*x*x*x
    +x;

下一个问题。使用带短路(short-circuiting)的逻辑运算,还是把逻辑表达式变成算术表达式?

// version 1	
  if (xs[i] == 1 
   && xs[i+1] == 1 
   && xs[i+2] == 1 
   && xs[i+3] == 1) // xs are bools stored as ints
 
// version 2
  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)

还有一个问题。有分支的交换排序和无分支的索引排序哪个排序 triplets 更快?

// version 1
    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]);
 
// version 2
    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;

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

1.如果使用 clang 11 以 -O2 -march=native 构建多项式模型,并在英特尔酷睿 i7-9700F 上运行,其速度是标准正弦的 3 倍。但如果使用 NVCC 和 --use-fast-math,并在 GPU 即 GeForce GTX 1050 Ti Mobile 上运行,标准正弦比该模型快 10 倍。

2.在 i7 上用短路(short-circuiting)逻辑换取矢量化算术也是合理的。这使片段的运行速度提高了一倍。但在使用相同 clang 和 -O2 的 ARMv7 上,标准逻辑比微优化快 25%。

3.索引排序与交换排序相比,索引排序在英特尔上要快 3 倍,而交换排序在 GeForce 上要快 3 倍。

因此,我们都非常喜欢的微优化既可以将代码的速度提高 3 倍,也可以将速度降低 90%。这一切都取决于环境。如果编译器能为我们选择最佳的替代方案,例如,当我们切换编译目标时,索引排序会奇迹般地变成交换排序,那该有多好。但编译器不可能做到这一点。

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

2.在第二个例子中,编译器并不知道我们的值仅限于 0 或 1,因此不可能提出我们可以提出的优化建议。我们或许可以通过使用适当的 bool 类型来暗示这一点,但那将是一个完全不同的问题。

3.在第三个例子中,两段代码差别很大,无法被视为同义词。我们把代码说得太细了。如果只是 std::sort,编译器就可以更自由地选择算法。但编译器既不会选择索引排序,也不会选择交换排序,因为这两种算法在大型数组上的效率都很低,而 std::sort 可以使用通用的可迭代容器。

这就是 Spiral 的由来。这是卡内基梅隆大学和苏黎世埃德根理工学院的一个联合项目。TL&DR:信号处理专家们厌倦了为每一个新硬件手工重写他们最喜欢的算法,于是编写了一个程序来替他们完成这项工作。该程序接收算法的高级描述和硬件架构的详细描述,并对代码进行优化,直至为指定的硬件实现最高效的算法。

与 Fortran 及其类似程序的一个重要区别是,Spiral 真正解决的是数学意义上的优化问题。它将运行时间定义为目标函数,并在受硬件架构限制的执行变体因子空间中寻找全局最优。这是编译器从未真正做到的。

编译器不会寻找真正的最优。它只是按照程序员教给它的启发式方法来优化代码。从本质上讲,编译器并不像机器那样寻找最优解,而是像汇编程序员那样工作。一个好的编译器就像一个好的汇编程序员,但仅此而已。

Spiral 是一个研究项目。它的范围和预算都很有限。但它所取得的成果已经令人印象深刻。在快速傅立叶变换方面,他们的解决方案明显优于 MKL 和 FFTW 实现。他们的代码快了约 2 倍。即使在英特尔平台上也是如此。

MKL 是英特尔自己开发的数学内核库,因此是由最懂硬件的人开发的。而 WWTF A. K. A. “西方最快傅立叶变换 “是一个高度专业化的库,由最了解算法的人提供。它们都是各自领域的佼佼者,而 Spiral 比它们都快了两倍,这实在令人吃惊。

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

C++ 杀手之二 Numba

最好的编程语言是你已经非常熟悉的语言。连续几十年来,大多数程序员最熟悉的编程语言一直是 C 语言,它也是 TIOBE 指数的领头羊,其他 C 语言同类产品紧紧占据前十名。然而,就在两年前,一件闻所未闻的事情发生了。C 语言的第一名让给了其他语言。

这个 “别的东西 “似乎就是 Python。在上世纪 90 年代,Python 是一门没有人认真对待的语言,因为它是另一种脚本语言,我们已经有了很多脚本语言。

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

有人可能会继续说:”但 Python 不是编译语言”,这又是一个错误的说法。有很多 Python 编译器,其中最有前途的就是 Python 脚本。让我来解释一下。

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

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

我不知道他自己在一天之内就弄明白了。显然,Numba 无法使用原生 Python 列表,它只接受 NumPy 数组中的数据。于是,他想出了这个办法,并在 GPU 上运行了他的算法。用 Python。他没有遇到我花了几个月时间才解决的问题。你想在 Linux 上运行吗?没问题,就在 Linux 上运行。你想让它与 Python 代码保持一致吗?没问题,它就是 Python 代码。你想针对目标平台进行优化吗?也没问题。Numba会根据你运行代码的平台对代码进行优化,因为它不会提前编译,而是在已经部署的情况下按需编译。

是不是很棒?嗯,不。反正对我来说不是。我花了几个月的时间用 C++ 解决在 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++ 杀手之三 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 应用程序。没错,不是 “过去写”,而是 “现在写”。微软热衷于保护 Windows 与 Win32 API 的向后兼容性,因此所有 MASM32 程序都能在现代 PC 上正常运行。

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

第二段代码是 Web Assembly。它甚至不是宏汇编器,没有 “if “和 “while”,更像是浏览器的人可读机器代码。或者其他浏览器。从概念上讲,任何浏览器都可以。

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

但最有趣的代码是第三段。它就是 ForwardCom,一个由著名的 C++ 和汇编优化手册作者 Agner Fog 提议的汇编程序。就像 Web Assembly 一样,它所涵盖的与其说是一个汇编器,不如说是一套通用指令集,其目的不仅是为了实现向后兼容性,也是为了实现向前兼容性。因此,ForwardCom 被命名为 “ForwardCom”。ForwardCom 的全称是 “开放式前向兼容指令集架构“。换句话说,与其说它是一个汇编提案,不如说是一个和平条约提案。

我们知道,所有最常见的架构系列:x64、ARM 和 RISC-V 都有不同的指令集。但没有人知道保持这种方式的充分理由。除了最简单的处理器,所有现代处理器运行的都不是你输入的代码,而是它们将你的输入转换成的微代码。因此,不只是 M1 对英特尔有向后兼容性层,每个处理器基本上都对其所有早期版本有向后兼容性层。

那么,是什么阻碍了架构设计师们就类似的层达成一致,但又能向前兼容呢?除了直接竞争的公司之间相互冲突的雄心壮志之外,什么也没有。但是,如果处理器制造商能够在某一时刻达成共识,采用通用指令集,而不是为每一个其他竞争者实施新的兼容性层,那么 ForwardCom 将使汇编编程回归主流。这个前向兼容性层将治愈所有汇编程序员最严重的神经衰弱:”如果我为这个特定的体系结构编写了千载难逢的代码,而这个特定的体系结构在一年内就会被淘汰,那该怎么办?

有了前向兼容性层,它就永远不会过时。这就是问题所在。

汇编编程还受到一个神话的阻碍,即汇编编写很难,因此不切实际。Fog 的主张也解决了这个问题。如果人们认为用汇编语言编写程序很难,而用 C 语言编写程序却不难,那么我们就把汇编程序做成 C 语言的样子。没有充分的理由让现代汇编语言的外观与其祖父在 50 年代的外观一模一样。

你刚才也看到了三个汇编示例。没有一个看起来像 “传统 “汇编,也不应该像。

因此,ForwardCom 是一种可以编写最佳代码的汇编语言,它永远不会过时,也不会让你学习 “传统 “汇编语言。就所有实际考虑而言,它是未来的 C 语言。而不是 C++。

那么,С++最终会在什么时候消亡呢?

我们生活在一个后现代世界。除了人,什么都不会死。就像拉丁语从未真正消亡一样,就像 COBOL、Algol 68 和 Ada 一样–C++ 注定要在生与死之间永恒地半死不活下去。C++ 永远不会真正消亡,它只会被更新、更强大的技术挤出主流。

好吧,不是 “将被挤走”,而是 “正在被挤走”。我是作为一名 C++ 程序员来到现在的工作岗位的,而今天,我的工作日是从 Python 开始的。我写下方程式,SymPy 帮我求解,然后将解法翻译成 C++。然后,我把这些代码粘贴到 C++ 库中,甚至懒得对其进行格式化,因为 clang-tidy 会帮我这样做。静态分析器会检查我是否弄乱了命名空间,动态分析器会检查是否有内存泄漏。CI/CD 将负责跨平台编译。剖析器能帮我了解代码的实际运行情况,反汇编器则能帮我了解代码运行的原因。

如果我把 C++ 换成 “非 C++”,我 80% 的工作将保持不变。C++ 与我的大部分工作根本无关。这是否意味着对我来说,C++ 已经死了 80%?

本文文字及图片出自 The Real C++ Killers (Not You, Rust)

余下全文(1/3)
分享这篇文章:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注