我的 JavaScript 比你的 Rust 更快

2022-11-24 0 249

Josh Urbane 是一位从业人员多年的软件CTO,很喜欢在SNS媒体撷取控制技术观点。近日,他写了一首诗,历史记录了她们凭借着经验赢了与后辈开发人员赌的故事情节,而“我的 JavaScript 比你的 Rust 更快”的推论也是来自那个赌。他的故事情节或许能说明运行策略在研发实践中的必要性。

我的 JavaScript 比你的 Rust 更快

对我来说,软件CTO这体力活更让人高兴的一点是能指导开发人员理解最新的概念、影响她们的控制技术判断。有些开发人员不是很猖狂吗,那就用理论加现实吱吱打她们的脸;CTO还得负责创造出体验式的学习气氛,帮助Pierrefort的开发人员逐渐长大成熟。

最会让我在心里暗爽的事儿是一个不男不女开发人员突然跳出来、想挑战我的控制技术提议(从开发人员的视点看,CTO是一伙PR白银城提错误提议的白痴),所以不惜牺牲全部富翁秉持指出她们的办法更好。

难题是,我早已干T5800很久了,不用校正我就知道难题的正确答案是什么。因此那就来呗,咱们Pouillon见若只,我把那段故事情节历史记录了下来、在几年后整理成了今天的这首诗。

白科是一种“睿智”

老实讲,下面要讲的那个事早已过去好多年了,因此很多技术细节我早已说不清楚楚。大体上情形是结合当时项目组的知识储备、可用工具库和原有控制技术债务,我给出的提议是让大家使用 Node.js。

一个新一任最高级开发人员对她们刚领到的软件工程哲学博士合格证书很有自信心,想用“rap”的方式挫挫我的习气。她们听说我是双学位的软件工程,因此真的我压根儿不了解计算机下层原理。其实刚毕业那会我也指出她们很懂,但T5800丸山了,我越来越真的电脑系统像是魔力……

他的自信心并非毫无来由,那个推论如同“C++比 JavaScript 速度慢”,基本属于业内一致意见。但作为典型的CTO,我仍然秉持指出“取决情形TNUMBERA0512Ci”。

更具体地讲,“经过充分强化的 C++,的确比具有等同强化水平的 JavaScript 跑得更快”,毕竟 JavaScript 有着无法避免的执行开支(即使如此,我们也能把标识符载入动态程序来获得高度接近 C++的性能)。反正话已至此,那就梭了呗。

意外的是,JavaScript 标识符的确要比 C++版更快一点,所以从体系结构的角度来看,JS 版能由当前项目组姚学甲维护、不需要借助于其他部门的控制技术能力。

还好还好,其实我也不敢百分之百确定她们是对的,但考虑到那个用例中的内存对象大小可能是动态的、再加上那位年轻开发人员的确经验不足,因此我愿意不惜牺牲一把。

JS 比 C++还快,怎么实现的?

我猜大多数开发人员都理解不了这样的结果。这明显跟“编译”语言快于“解释”语言、“动态”程序快于“VM”程序的基本原则背道而驰啊。但请注意,这些只是经验、而非真理。

我之前也提到,“强化”才是决定速度的关键。毕竟即使 C++语言自身的性能优势再强,糟糕的编写质量也会让程序身陷泥潭。另一方面,Node.js(使用基于 C++/C 的 V8与 libuv 库)则更具强化空间,因此实际运行速度并不差。甚至能说,质量同样差劲的 JS 和 C++程序,JS 的性能可能还更好一点。但这只是宏观论述,下面咱们来看点技术细节。

内存是关键

大多数开发人员应该很熟悉栈和堆的概念,但这种理解基本只停留在了表面——例如只知道栈是线性的,而堆是带有指针的“坨”(并非严格术语,大家能理解就行)。

更重要的是,栈和堆的概念对应着多种实现和方法。下层硬件并不知道“堆”是个什么东西,因为内存的管理方式是由软件来定义的,而内存管理方面的选择必然会对程序的最终性能产生巨大影响。

大家也能就那个难题深挖下去,很有意义也很有价值。现代硬件和内核都相当复杂,其中往往包含大量具有特殊用途的强化机制,例如更高效地利用高级内存布局。这意味着软件能(或者必须)借用由硬件提供的内存管理功能。此外还有虚拟化的影响……这里就不多做展开了。

魔力的核心:垃圾回收

没错,Node.js 解决方案的启动时间肯定更长,因为它需要通过 JIT 编译器来实现脚本的加载和运行。不过一旦加载完成,Node.js 标识符其实反而拥有一项神秘的优势——垃圾回收机制。

而在 C++程序中,应用程序往往会在堆中创建动态大小的对象,之后再将其删除。这意味着程序的分配器必须一遍又一遍地在堆中分配和释放内存。这项操作本身速度较慢,所以实际性能基本由分配器中的算法决定。在多数情形下,dealloc 的速度会特别慢,即使是精简后的 alloc 也没好太多。

对于 Node.js 程序,这项绝技是程序只运行一次就会退出。Node.js 同样运行脚本并分配必要的内存,但后面的删除操作会由垃圾回收器挑选空闲时间再推迟执行。

诚然,垃圾回收机制在本质上并不比其他内存管理策略更好或者更差(一切都是权衡),但在我们赌的那个特定程序中,垃圾回收的确能显著提升性能,因为那个程序压根儿就没真正运行过。我们只是把一大堆对象塞进内存,再在退出时一次性丢弃。

垃圾回收肯定是有代价的,Node.js 进程占用的内存容量明显大于 C++程序。这是“省 cpu=费内存”和“省内存= 费 cpu”的经典难题,但我的目标是打那小子的脸,因此费点内存也无所谓。

而我之因此能赢,是因为对方选择了一个幼稚的策略。其实他要想赢,最好的办法是添加内存泄漏,故意把所有分配都保留在内存当中。这样 C++程序的内存占用量还是更小,但速度却比原先快得多。或者,他也能用给栈分配缓冲区之类的设计来进一步提高性能,这种办法在实际生产中其实经常用到。

另外还有如何选择性能基准的难题。一般来说,大家比较的是每秒操作数量。这里的 JS 对 C++是个很好的例子,证明了“先理解总体性能成本,再做选择”往往更加靠谱。在软件开发中,我们必须得时刻关注资源层面的“总体拥有成本”。

步入现代:有请 Rust 上场

Rust 是我目前最喜欢的语言之一。它提供了很多现代特性、速度很快,所以具备良好的内存模型,生成的标识符也相当安全。

Rust 当然不是完美的,它的编译时间比较长、涉及不少奇奇怪怪的语义,但总体来说还是值得推荐。大家能对 Rust 中的内存管理方式进行灵活控制,但其“栈”内存始终遵循所有者模型(ownership model),这也是其实现引以为傲的高安全性能的基础。

我目前参与的一个项目是用 Rust 编写的 FaaS(函数即服务)主机,负责执行 WASM(WebAssembly)函数。它能快速安全地执行各项隔离函数,最大限度降低 FaaS 的运行开支。它的速度也很快,每核心每秒能够处理90000个简单请求。更重要的是,它的总内存占用量只有20 MB 上下,能说相当夸张了。

但这跟 Node.js 与 C++的赌局有什么关系?

简单来说,我是把 Node.js 视为“合理”的性能基准(Go 属于梦幻级基准,它的性能绝对不是那些专为 Web 服务设计的语言能比肩的,这里就别降维打击了),毕竟我们那款程序的早期 C++版性能实在不咋的,唯一的好处是内存占用量只有 Node.js 版的不到十分之一。

虽然先让标识符跑起来、再对标识符做强化的确没啥毛病,但在 C++这种“快”语言上输给了 JavaScript 肯定让人非常沮丧。而我之因此敢当场白科,靠的是对明显瓶颈的基本判断。那个瓶颈是内存管理。

每个 guest 函数都被分配到一个内存数组,但在函数之内分配内存,以及在函数内存与主机内存间复制数据肯定会带来大量性能开支。由于动态数据被四处乱扔,分配器相当于是饱受四面八方的重拳打击。至于解决办法嘛,作弊喽!

加堆,两个堆、三个堆……

从本质上讲,堆代表的是分配器用来管理映射的一部分内存。程序会请求 N 个内存单元,分配器在可用的内存池里搜寻这些单元(或者向主机请求更多内存)及存储哪些单元已被占用,之后再返回该内存的位置指针。当程序用尽内存时,就会告知分配器,再由分配器更新映射以明确现在哪些单元早已再次可用。挺简单的,对吧?

但如果我们需要分配一大堆生命周期有别、大小各异的内存单元时,麻烦就来了。这一定会产生大量碎片,进而放大了新内存的分配成本。于是性能损失开始产生,毕竟分配器的功能太过简单,只是在寻找可用的存储位置。

那个难题显然没有太好的解决方案,虽然目前可选的分配算法很多,但它们还是各有权衡、要求我们结合用例特点选择最适方法(也能像大多数开发人员一样,直接用默认选项)。

再来说作弊。作弊的办法可不只一种:对于 FaaS,我们能释放每次运行的 dealloc,并在每次运行完成后清除整个堆;我们也能在函数生命周期的不同阶段使用不同的分配器,例如明确区分初始化阶段和运行阶段。这样无论是干净的函数(每次运行,都会被重置为相同的初始内存状态)还是有状态函数(在每次运行之间保留状态),都能获得与之对应且经过强化的内存策略。

在我们的 FaaS 项目里,大家最终构建了一个动态分配器,它会根据使用情形选择分配算法、且实际选择会在每次运行之间持续留存。

对于“使用率较低”的函数(也是大多数函数),只使用简单的栈分配器用指针指向下一个空闲槽即可。当调用 dealloc 时,如果该单元为栈上的最后一个单元,则回滚指针;如果不是最后一个单元,则无操作。当函数完成时,指针将被设置为0(相当于 Node.js 在垃圾回收前退出)。

如果函数的 dealloc 失败数和用量达到一定阈值,则在其余调用中使用其他分配算法。结果是,这套方案在大多数情形下都能显著加快内存分配。

运行时中还用到了另一个“堆”——主机(或者说是函数共享内存)。它使用同样的动态分配策略,并允许绕过早期 C++版中的复制步骤、直接写入函数内存。如此一来,I/O 就能直接从内核中复制 guest 函数,并绕过主机运行时,从而显著提高吞吐量。

Node.js 对阵 Rust

经过强化,Rust FaaS 运行时最终比我们的 Node.js 参考实现快了70%以上,而内存占用量更是不到后者的十分之一。

但这里的关键在于“经过强化”,它的初始实现其实速度反而更慢。我们的强化还要求对 WASM 函数做出一些限制,具体限制在编译过程中完全公开透明,所以极少出现不兼容的情形。

Rust 版的最大优势是内存占用小,省下来的 RAM 能用作缓存或者分布式内存存储等其他用途。这意味着 I/O 开支进一步降低,生产运行的效率更高,其效果甚至比拉高 CPU 配置还更明显些。

后续我们还有更多强化计划,但主要是为了解决主机层中一些具有重大安全影响的难题。虽然跟内存管理或者性能没啥关系,但毕竟也算支持了“Rust 比 Node 更快”党们的观点。

总结

其实全文写下来,我也得不出特别明确的推论。下面只给出几个粗浅的观点:

内存管理很有趣,每种方法都是在做取舍。只要策略运用得当,任何一种语言都能获得巨大的性能提升。我仍然推荐大家根据实际目标灵活使用 Node.js 和 Rust,因此这里不做优劣判断。JavaScript 的可移植性的确更好,所以特别适合云原生开发场景;但如果大家特别看重性能,那 Rust 可能是个更好的选择。从头到尾我都在说 JavaScript,但这里实际指的是 TypeScript。

归根结底,大家得根据实际情形选择最适合的控制技术方案。我们越是了解不同栈的不同特征,在选择的时候就越是从容有数。

相关文章

发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务