原副标题:愤慨, c++ addons 比 nodejs间接写还慢?why?
愤慨, c++ addons 比 nodejs间接写还慢?why?
译者:delenzhang,百度CDG前端开发技师
| 编者按前段时间课余时间给Electron做了两个c++ addons的node组件,疑惑词汇憎恶链顶部的c/c艹 的究竟比nodejs快啥,只好写了两个demo试验呵呵,结论辨认出…
不可否认, nodejs 运转发动机是采用c++编写的完全免费开放源码 Java 和 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 Java and C/C++ libraries.c++ 应用程序是用 C++ 编写的静态镜像共享资源第一类。 require 表达式能像一般的 Node.js 组件那样读取应用程序。 Addons 提供更多了 Java 和 C/C++ 库间的USB。
所以采用c++ addons与否能把nodejs的方法论改写后,与否能大幅地提升操控性?
已经开始编写demo
先采用node-gyp校对两个 node组件,标识符如下表所示:
// calculate.cc#include namespace calculate { using v8::Number; void Method(const FunctionCallbackInfo &args) { Isolate* isolate = args.GetIsolate; // 核心理念耗时方法论 int value = args[0].As ->Value; int i; double x = 100.734659, y = 353.2313423432; for (i=0; i < value; i++) { x += y; } } void Initialize(Local exports) { NODE_SET_METHOD(exports, “calc”, Method); } NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize) }
标识符比较简单,这里我没用一些比较耗时间的算法 如 计算素数或者 斐波拉切计算,而是最简单的多次浮点数加法运转来测。考虑到JS没有单独的浮点型,浮点数与整数都是通过 Number 类型表示,是遵循 IEEE754标准的 64 位双精度值。
为了尽可能地考虑到 控制变量法的实验思想,c++ 里也是采用double。 这里采用的同类的nodejs文件间接两个文件下搞定,内容如下表所示
// calculate.jsconst calculate = require(./build/Release/calculate)// 核心理念双精度浮点数计算方法论function calc(n) { let i, x = 100.734659, y=353.2313423432; for (i=0; i
下面能间接运转 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 &args) { Isolate* isolate = args.GetIsolate; // 核心理念耗时方法论 int value = args[0].As ->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
whats your problem?运转结论差距这么大,我就导出了数据而已,nodejs 完胜了c++ addons 了。简单的浮点数传递竟然如此消耗操控性 。
你干嘛!
遇事不决,chatgpt。
哦,这样,chatgpt 好像给了解释,又好像什么都没说。再问下bing
国外的开发人员这种探索精神确实很赞,很早就有了同样的疑问并给出了结论。 这里只是简单的跨底层发动机数据传递就消耗了足够大的操控性,就算底层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 ->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
试验结论:
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耗时的程序进行优化,这部分的操控性折损也是完全能接收到。
总结呵呵:
涉及值传递的,如果nodejs有大量的方法论运算损耗大量的时间,c++优化的时间能超过值传递带来的损耗,c++ 优于nodejs, 否则 nodejs优于c++ 不涉及值传递的,大部分情况下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)上的关键 IC 会为每个表达式维护两个反馈向量 (FeedBack Vector),反馈向量记录了表达式在执行过程中的一些关键的中间数据。 反馈向量其实就是两个表结构,它由很多项组成的,每一项称为两个插槽 (Slot),V8 会依次将执行 calc 表达式的中间数据写入到反馈向量的插槽中。 当 V8 再次调用 calc 表达式时,比如执行到 calc 表达式中的 return x语句时,它就会在对应的插槽中查找 x 属性的偏移量,之后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 替换也不一定能达到肉眼可见的操控性优化。 国外的小伙伴已经做过试验了,这里间接引入呵呵实验结论:
Java 的 Set 操控性甚至成为赢家,这是我们唯一看到纯 Java 在相当高的 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 Java
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 创建了两个APIUSB服务,采用abTest 压测呵呵USB操控性。
// 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
先测呵呵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 1s (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”);
缓冲区同样能转换回传统的 Java ,供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 」发动机,让你更懂Java ! C++ addon is slower than js Using Buffers to share data between Node.js and C++