天天看点

震惊, c++ addons 比 nodejs直接写还慢?why?

作者:腾讯技术工程

作者:delenzhang,腾讯CDG前端开发工程师

| 导语最近业余时间给Electron做了一个c++ addons的node模块,好奇语言鄙视链顶端的c/c艹 的到底比nodejs快多少,于是写了一个demo测试一下,结果发现...

众所周知, nodejs 运行引擎是使用c++编写的免费开源 JavaScript 和 WebAssembly 引擎v8 engine。而 c++ addons 为nodejs开发者提供了一种无中间商赚差价的方式使用 C/C++ 的能力。 先看一下官方文档的介绍

Addons are dynamically-linked shared objects written in C++. The [`require()`](https://nodejs.org/dist/latest-v18.x/docs/api/modules.html#requireid) function can load addons as ordinary Node.js modules. Addons provide an interface between JavaScript and C/C++ libraries.
c++ 插件是用 C++ 编写的动态链接共享对象。 require() 函数可以像普通的 Node.js 模块一样加载插件。 Addons 提供了 JavaScript 和 C/C++ 库之间的接口。           

那么使用c++ addons是否能把nodejs的逻辑重写后,是否能大幅度地提高性能?

开始撰写demo

先使用node-gyp编译一个 node模块,代码如下:

// calculate.cc
#include <node.h>
namespace calculate {
    using v8::Number;

    void Method(const FunctionCallbackInfo<Value>&args) {
        Isolate* isolate = args.GetIsolate();

        // 核心耗时逻辑
        int value = args[0].As<Number>()->Value();
        int i;
        double x = 100.734659, y = 353.2313423432;
        for (i=0; i < value; i++) {
            x += y;
        }
    }

    void Initialize(Local<Object> exports) {
        NODE_SET_METHOD(exports, "calc", Method);
    }
    NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
}           

代码比较简单,这里我没用一些比较耗时间的算法 如 计算素数或者 斐波拉切计算,而是最简单的多次浮点数加法运行来测。考虑到JS没有单独的浮点型,浮点数与整数都是通过 Number 类型表示,是遵循 IEEE754标准 的 64 位双精度值。

震惊, c++ addons 比 nodejs直接写还慢?why?

为了尽可能地考虑到 控制变量法 的实验思想,c++ 里也是使用double。 这里使用的同类的nodejs文件直接一个文件下搞定,内容如下

// calculate.js
const calculate = require('./build/Release/calculate')

// 核心双精度浮点数计算逻辑
function calc(n) {
    let i, x =  100.734659, y=353.2313423432;
    for (i=0; i<n; i++) {
        x += y;
    }
	return x
}

console.log('c++ addon result vs Node result')
let keyCpp = 'c++ useTime:'
console.time(keyCpp)
calculate.calc(10000)
console.timeEnd(keyCpp)

let key = "nodejs useTime:"
console.time(key)
calc(10000)
console.timeEnd(key)           

下面可以直接运行 node calculate.js 得到结果

测试

先上结果

c++ addon result vs Node result
c++ useTime:: 0.097ms
nodejs useTime:: 0.4ms           

好家伙, 这速度杠杠的,直接提升 312% 倍。还是c艹牛,性能真是杠杠的,好了,散了吧。

...

不对,我怎么知道我结果是对的呢,万一是错的呢,要不把结果传递出来对比下。

//calculate.cc
...
void Method(const FunctionCallbackInfo<Value>&args) {
        Isolate* isolate = args.GetIsolate();

        // 核心耗时逻辑
        int value = args[0].As<Number>()->Value();
        int i;
        double x = 100.734659, y = 353.2313423432;
        for (i=0; i < value; i++) {
            x += y;
        }
		// 新增导出结果
        auto total = Number::New(isolate, x);
        args.GetReturnValue().Set(total);
    }
...           

测试代码新增

// calculate.js
...
console.time(keyCpp)
console.log("c++ 计算结果:", calculate.calc(10000))
console.timeEnd(keyCpp)

console.time(key)
console.log("nodejs 计算结果:", calc(10000))
console.timeEnd(key)
...           

再跑一下 node calculate.js 看下

c++ addon result vs Node result
c++ useTime:: 0.15ms
nodejs useTime:: 0.552ms
c++ 计算结果: 3532414.1580905635
c++ useTime:: 0.513ms
nodejs 计算结果: 3532414.1580905635
nodejs useTime:: 0.402ms           

what's your problem? 运行结果差距这么大,我就导出了数据而已,nodejs 完胜了c++ addons 了。简单的浮点数传递竟然如此消耗性能 。

你干嘛!

遇事不决,chatgpt。

震惊, c++ addons 比 nodejs直接写还慢?why?

哦,这样,chatgpt 好像给了解释,又好像什么都没说。再问下bing

震惊, c++ addons 比 nodejs直接写还慢?why?

国外的开发者这种探索精神确实很赞,很早就有了同样的疑问并给出了结论。 这里只是简单的跨底层引擎数据传递就消耗了足够大的性能,就算底层c++对性能进行了高达300%的性能优化,也抵不过一次的数据值传递。

这里 补充一下@johnche大佬给的测试建议,毕竟double类型的c++数据传递到js里v8需要使用HeapNumber, v8内js的数字分为两种类型,分别是sim和HeapNumber。Smi,小整数,顾名思义用来表示一个小范围内的整数类型: -(230) ~ 230 - 1 范围内的整数,我们知道Int32类型的范围是 -(231) ~ 231 - 1, 为什么Smi类型会比Int32小呢,这是因为在V8中,Sim类型的值是根据它的地址直接得出的,为了区分Smi类型和普通的指针,Smi类型都存储在最低位为0的地址中,所以Smi的范围实际上是Int31类型的范围。

与Sim对应,HeapNumber则用来表示无法用Smi表示其他Number类型,包括有小数点的数值, 超过Smi范围的整数, Number.NaN, Infinity等任何不能用Smi表示的Number类型。HeapNumber在V8内部是一个对象,储存在堆内存上,它的名字也体现出了这一点。HeapNumber类型的值是不可变,如果要修改,会创建一个新的HeapNumber并赋值。

难道是是因为是浮点数导致HeapNumber的new导致了巨大的性能损耗。于是代码修改如下进行测试:

calculate.cc
 int value = args[0].As<Number>()->Value();
        int i;
        int x = 200, y = 353;
        for (i=0; i < value; i++) {
            x += y;
        }
//        auto total = Number::New(isolate, x);
        args.GetReturnValue().Set(x);           
calculate.js
// 核心双精度浮点数计算逻辑
function calc(n) {
    let i, x =  200, y=353;
    for (i=0; i<n; i++) {
        x += y;
    }
    return x
}

// let times = 1
console.log('c++ addon result vs Node result')
let keyCpp = 'c++ useTime:'
console.time(keyCpp)
calculate.calc(10000)
console.timeEnd(keyCpp)

let key = "nodejs useTime:"
console.time(key)
calc(10000)
console.timeEnd(key)

console.time(keyCpp)
console.log("c++ 计算结果:", calculate.calc(10000))
console.timeEnd(keyCpp)           

测试结果:

c++ addon result vs Node result
c++ useTime:: 0.083ms
nodejs useTime:: 0.254ms
c++ 计算结果: 3530200
c++ useTime:: 0.663ms
nodejs 计算结果: 3530200
nodejs useTime:: 0.258ms           

这里可以看出c++提升的性能优化依然被值拷贝给损耗了,不过可以看出c++到js的值传递其实已经是很快了,基本是0.5ms左右的耗时,如果使用c++对严重cpu耗时的程序进行优化,这部分的性能折损也是完全可以接收到。

总结一下:

  1. 涉及值传递的,如果nodejs有大量的逻辑运算损耗大量的时间,c++优化的时间能超过值传递带来的损耗,c++ 优于nodejs, 否则 nodejs优于c++
  2. 不涉及值传递的,大部分情况下c++优于nodejs

我们目前一些流行框架都能找到同样的问题,比如被大众诟病的 ReactNative的性能问题,又何尝不是一次次的js和native的消息传递的消耗。 比如小程序的setData每次都是性能优化的重头之重。比如 jsbridge的调用,每次开发都要注意不要频繁地调用,引发性能问题。

继续回归到c++ addons的优化,如果不进行值的传递,只是调用c++原生功能作为处理即可,是否能弥补nodejs的性能短板呢,为后台部署高并发的nodejs 服务器做一下性能优化,这里把测试代码改成如下形式来模拟一下多次执行 。

// calculate.js
...
let time = 100000
console.log(\`------ 以下模拟并发运行${time}次--------\`)
console.time(keyCpp)
for (let i = 0; i < time; i++) {
    calculate.calc(10000)
}
console.timeEnd(keyCpp)

console.time(key)
for (let i = 0; i < time; i++) {
    calc(10000)
}
console.timeEnd(key)           

运行结果:

c++ addon result vs Node result
c++ useTime:: 0.091ms
nodejs useTime:: 1.02ms
c++ 计算结果: 3532414.1580905635
c++ useTime:: 0.4ms
nodejs 计算结果: 3532414.1580905635
nodejs useTime:: 0.308ms
------ 以下模拟并发运行100000次--------
c++ useTime:: 1.079s
nodejs useTime:: 1.038s
           

多次运行测试发现耗时相差不大,约等于相同, 并没有前面只运行一次性能优化巨大,这里可以看出v8 engine 对这一部分应该进行了很大的优化提升,这里就不得不提一下v8 内联缓存,这里其实就是v8 通过内联缓存来提升函数执行效率。

这里以范例为例说一下v8的内联缓存及其原理:

  • 函数 calc 在一个 for 循环里面被重复执行了很多次,因此 V8 会想尽一切办法来压缩这个查找过程,以提升对象的查找效率。这个加速函数执行的策略就是内联缓存 (Inline Cache),简称为 IC;
  • IC 的原理:在 V8 执行函数的过程中,会观察函数中一些调用点 (CallSite) 上的关键中间数据,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此 V8 利用 IC,可以有效提升一些重复代码的执行效率。
  • IC 会为每个函数维护一个反馈向量 (FeedBack Vector),反馈向量记录了函数在执行过程中的一些关键的中间数据。
  • 反馈向量其实就是一个表结构,它由很多项组成的,每一项称为一个插槽 (Slot),V8 会依次将执行 calc 函数的中间数据写入到反馈向量的插槽中。
  • 当 V8 再次调用 calc 函数时,比如执行到 calc 函数中的 return x语句时,它就会在对应的插槽中查找 x 属性的偏移量,之后 V8 就能直接去内存中获取 x 的值了。这样就大大提升了 V8 的执行效率。

V8 引入了内联缓存(IC),IC 会监听每个函数的执行过程,并在一些关键的地方埋下监听点,这些包括了加载对象属性 (Load)、给对象属性赋值 (Store)、还有函数调用 (Call),V8 会将监听到的数据写入一个称为反馈向量 (FeedBack Vector) 的结构中,同时 V8 会为每个执行的函数维护一个反馈向量。有了反馈向量缓存的临时数据,V8 就可以缩短对象属性的查找路径,从而提升执行效率。(PS: v8 engine 内部的js执行的优化和实现,这里只是一些皮毛,很值得大家去进一步学习和钻研)

此外,es6也提供的一些高性能的数据集合 如 Set, Map 等,同样也是在v8 engine内部进行过极致的性能优化,如果单纯地使用 c++的 unordered_set、unordered_map 替换也不一定能达到肉眼可见的性能优化。 国外的小伙伴已经做过测试了,这里直接引入一下实验结果:

震惊, c++ addons 比 nodejs直接写还慢?why?
Javascript 的 Set 性能甚至成为赢家,这是我们唯一看到纯 Javascript 在相当高的 N 中战胜 C++ 插件的情况。所以如果你想使用一些数据结构,你最好使用原生 ES6 数据结构。           

文章地址会放到最后,有兴趣的小伙伴可以自己尝试一下。

所以对于使用 c++ addons的性能优化真的不是简单重写一遍那么简单,还需要考虑到很多问题,比如是否有数据之间的传递,对于这种值类型的数据传递,各个引擎需要自己重新分配空间,这种消耗无疑是巨大的,以及是否在v8 engine下已经做好了优化的数据结构或者操作优化,c++ addons带来的性能提升的价值远远抵消不了开发便利性的损耗。 那什么时候适合使用c++ addons来替换nodejs呢?nodejs/node-addon-api 维护者 NickNaso是这么说到

you can improve performance specially for CPU bound operations (think about at image processing).           

并提供了 以下几个范例说明c++ addons 提升性能的使用场景, 有兴趣的同学可以自己试一试:

Pure JavaScript Native add-on
bcryptjs bcrypt
jimp sharp
crc64-ecma182.js crc64-ecma182

回归到上面的测试用例,如果我们要使用 c++ addons进行优化,就需要将多次循环执行的运算一起放到c++ 里进行

let time = 100000
for (let i = 0; i < time; i++) {
    calculate.calc(10000)
}           

改成

int time = 10000
for (int j = 0; j < time; j++){
   for (i=0; i < value; i++) {
	   x += y;
	}
}           

即可完成一次执行的优化。

好了,

下面做一个nodejs的并发服务测试一下

nestjs并发服务测试

这里使用nestjs 创建了两个API接口服务,使用abTest 压测一下接口性能。

// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

const calculate = require('../helper/calculate')

function calc(n) {
  let i, x =  100.734659, y=353.2313423432;
  for (i=0; i<n; i++) {
    x += y;
  }
  return x
}

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('nodejs')
  getNodejs(): string {
    calc(10000);
    return "nodejs ok";
  }
  @Get('cpp')
  getCpp(): string {
    calculate.calc(10000);
    return "c++ ok";
  }
}           

先测一下nodejs 原生的性能:

ab -n 1000 -c 1000  http://127.0.0.1:3000/nodejs
# 测试结果
Document Path:          /nodejs
Document Length:        2 bytes

Concurrency Level:      1000
Time taken for tests:   0.460 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      200000 bytes
HTML transferred:       2000 bytes
Requests per second:    2171.67 [#/sec] (mean)
Time per request:       460.476 [ms] (mean)
Time per request:       0.460 [ms] (mean, across all concurrent requests)
Transfer rate:          424.15 [Kbytes/sec] received
           

c++ addons的测试结果

ab -n 1000 -c 1000 http://127.0.0.1:3000/cpp
# 测试结果
Document Path:          /cpp
Document Length:        2 bytes

Concurrency Level:      1000
Time taken for tests:   0.421 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      200000 bytes
HTML transferred:       2000 bytes
Requests per second:    2376.32 [#/sec] (mean)
Time per request:       420.818 [ms] (mean)
Time per request:       0.421 [ms] (mean, across all concurrent requests)
Transfer rate:          464.13 [Kbytes/sec] received           

这里可以看出并发数据有近9.4%的提升,请求时间都有近9.5%的下降。 这里再次佐证了NickNaso的说法。

如果我们有数据的交互,把c++ addons里的数据运算结果再返回到nodejs返回,又是什么效果呢?代码修改如下:

...
  @Get('nodejs')
  getNodejs(): string {
    let num = calc(100000);
    return "" + num;
  }
  @Get('cpp')
  getCpp(): string {
    let num = calculate.calc(100000);
    return "" + num;
  }
  ...           

nodejs 原生写法测试结果如下

ab -n 1000 -c 1000  http://127.0.0.1:3000/nodejs

Document Path:          /nodejs
Document Length:        18 bytes

Concurrency Level:      1000
Time taken for tests:   0.450 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      218000 bytes
HTML transferred:       18000 bytes
Requests per second:    2222.43 [#/sec] (mean)
Time per request:       449.957 [ms] (mean)
Time per request:       0.450 [ms] (mean, across all concurrent requests)
Transfer rate:          473.14 [Kbytes/sec] received           

c++ addons 测试结果

ab -n 1000 -c 1000  http://127.0.0.1:3000/cpp

Document Path:          /cpp
Document Length:        18 bytes

Concurrency Level:      1000
Time taken for tests:   0.504 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      218000 bytes
HTML transferred:       18000 bytes
Requests per second:    1985.61 [#/sec] (mean)
Time per request:       503.623 [ms] (mean)
Time per request:       0.504 [ms] (mean, across all concurrent requests)
Transfer rate:          422.72 [Kbytes/sec] received           

这么一看涉及到数据传递之后,c++ addons 如果性能提升不是很明显的话,涉及到和nodejs的值传递, 请求时间和QPS反而不如nodejs原生的性能更好。

那么使用c++ addons难免会遇到值的拷贝传递,那么c++ addons 优化的速度就无法避免了吗?

抛开解决、查找、加载等模块的时间,大部分 CPU 运行周期将用于 Node.js 和 C++ 之间的封送处理数据(涉及到跨语言信息传递的都是如此)。其中更加昂贵的是字符串的传递。如果 Node.js 内部使用 UTF-8,那么成本将是最小的。如果 Node.js 使用 UTF-16 或依赖于没有 ASCII 快速路径的 libicu,那么对于 ASCII 数据(也包括 JSON),转换的成本会更高一些。

在拷贝字符串数据过程中,分配内存来接收副本相对来说更加损耗性能。浮点数和整数转换相对于字符串的传递性能损耗小的多,因为 Node.js 可能会使用 64 位整数和双精度类型(因为它本身可能是用 C/C++ 编写的),复制这些值的成本非常低,因为它们很可能都将在堆栈上传递。如果必须应用类型转换或需要复制数据(因此需要分配内存),传递值数组一定会产生一些开销。

既然我们知道了在反复的值拷贝的过程中都要不断地创建和销毁内存,如果我们把需要修改的内存内容提前声明好,然后js 和 c++ 复用同一段内存来减少值拷贝的损耗。尤其是字符串的拷贝损耗是比较严重的,这里就可以借助buffers进行优化处理。借用 Node.js 文档中的一些示例,我们可以创建指定大小的初始化缓冲区、预先设置有指定值的缓冲区、字节数组缓冲区和字符串缓冲区。

// buffer with size 10 bytes
**const** buf1 = Buffer.alloc(10);
// buffer filled with 1's (10 bytes)
**const** buf2 = Buffer.alloc(10, 1);
//buffer containing [0x1, 0x2, 0x3]
**const** buf3 = Buffer.from([1, 2, 3]); 
// buffer containing ASCII bytes [0x74, 0x65, 0x73, 0x74].
**const** buf4 = Buffer.from('test');
// buffer containing bytes from a file
**const** buf5 = fs.readFileSync("some file");           

缓冲区同样可以转换回传统的 JavaScript ,供js使用,这样就可以极大地提高性能。具体可参考Using Buffers to share data between Node.js and C++

以上算是测试的全部内容了,代码地址。由此也产生了一些思考,性能和效率在软件工程上看来并没有银弹,鱼和熊掌不可得兼,这难道就是咱们老祖宗说的中庸之道吗?审时度势,量力而行,不盲从,不偏信。根据所处复杂开发环境因素、团队技能点的长处选择合适的开发框架和解决方案的能力,遇到问题深入探索的精神,以及深入地定位问题的本质,我想这会不会是目前短期内不能被chatgpt取代的原因之一呢,毕竟这些都是内部资料不能上传给gpt?

文章参考

  • Do C++ Addons Improve Node JS Performance? A Benchmark
  • Speed up Your Node.js App with Native Addons
  • 详解 Chrome 「V8 」引擎,让你更懂JavaScript !
  • C++ addon is slower than js
  • Using Buffers to share data between Node.js and C++