前面的例子主要还是感受 addon 的功能实现,接下来看一个相对复杂一点点的例子,研究下 addon 对比 JS 的性能,我们以斐波那契数列的实现作为例子。
Addon 实现
按照之前的模样把架子实现下:
#include <node.h>namespace demo {using v8::Number;using v8::Local;using v8::FunctionCallbackInfo;using v8::Value;void Fib(const FunctionCallbackInfo<Value> &args) {v8::Isolate *isolate = args.GetIsolate();Local<v8::Context> context = isolate->GetCurrentContext();// TODOargs.GetReturnValue().Set(Number::New(isolate, result));}void Init(Local<v8::Object> exports, Local<v8::Object> module) {NODE_SET_METHOD(exports, "fib", Fib);}// 这里的 NODE_GYP_MODULE_NAME 宏就是 前面 binding.gyp 的指定的 target_nameNODE_MODULE(NODE_GYP_MODULE_NAME, Init)} // namespace demo
接下来我们填充 TODO 里的内容,斐波那契数列的原理就不多说了,直接上代码吧:
#include <node.h>namespace demo {using v8::Number;using v8::Local;using v8::FunctionCallbackInfo;using v8::Value;void Fib(const FunctionCallbackInfo<Value> &args) {v8::Isolate *isolate = args.GetIsolate();Local<v8::Context> context = isolate->GetCurrentContext();uint64_t num = args[0]->IntegerValue(context).ToChecked();uint64_t result = 0, a = 1, b = 1;if (num <= 2) {result = 1;} else {while (num-- > 2) {result = a + b;a = b;b = result;}}args.GetReturnValue().Set(Number::New(isolate, result));}...} // namespace demo
简单看下代码,14 行获取入参就是一个指定取斐波那契数列的第几项,这里因为是正整数(先不考虑异常输入,关于异常后面再说)用 uint64_t 的类型,即无符号 64 位整数,然后就是一个循环,逐步累加了,代码逻辑看起来没啥毛病,还是和之前一样配置下编译,然后写个 JS 调用。
const { fib } = require('./build/Release/fib.node');console.log(fib(10));console.log(fib(64));
测试下:
5510610209857723
对比 JS 版本
接下来写个 JS 版本,也很简单,为了节省时间就不啰嗦验证计算结果与前面的版本一样了。
function fib(num) {if (num <= 2) {return 1;} else {let result, a = 1, b = 1;while (num-- > 2) {result = a + b;a = b;b = result;}return result;}}
然后我们对比下两个实现的性能,由于单次计算耗时太低了用 ms 看可能看不出效果,我们用上 node.js 里提供的高精度的可以到 ns 的计时器来计算下耗时:
const { fib: fibAddon } = require('./build/Release/fib.node');function fib(num) {if (num <= 2) {return 1;} else {let result, a = 1, b = 1;while (num-- > 2) {result = a + b;a = b;b = result;}return result;}}const t1 = process.hrtime.bigint();console.log(fibAddon(64));const t2 = process.hrtime.bigint();console.log(fib(64));console.log(`addon: ${t2 - t1}ns, js: ${process.hrtime.bigint() - t2}ns`);
看看结果:
node-addon-playground(main✗)» node ./2-fib/index2.js [12:33:33]1061020985772310610209857723addon: 7349237ns, js: 194593ns
咦?!!!怎么可能呢?JS 执行居然比 addon 快了一个数量级?问题出在哪儿呢?
核心原因其实是出在 while 循环的比较,我们是把次数的 loop 与 2 相比,这样从编码的角度性能是不高的,如果改成 0 在机器码的对比上会快得多,我们改一下代码再对比下:
//#include <node.h>namespace demo {using v8::Number;using v8::Local;using v8::FunctionCallbackInfo;using v8::Value;void Fib(const FunctionCallbackInfo<Value> &args) {v8::Isolate *isolate = args.GetIsolate();Local<v8::Context> context = isolate->GetCurrentContext();uint64_t num = args[0]->IntegerValue(context).ToChecked();uint64_t result = 0, a = 1, b = 1;if (num <= 2) {result = 1;} else {while (num-- > 2) {result = a + b;a = b;b = result;}}args.GetReturnValue().Set(Number::New(isolate, result));}void Fib2(const FunctionCallbackInfo<Value> &args) {v8::Isolate *isolate = args.GetIsolate();Local<v8::Context> context = isolate->GetCurrentContext();uint64_t num = args[0]->IntegerValue(context).ToChecked();uint64_t result = 0, a = 1, b = 1;if (num <= 2) {result = 1;} else {num -= 2;while (num-- > 0) {result = a + b;a = b;b = result;}}args.GetReturnValue().Set(Number::New(isolate, result));}void Init(Local<v8::Object> exports, Local<v8::Object> module) {NODE_SET_METHOD(exports, "fib", Fib);NODE_SET_METHOD(exports, "fib2", Fib2);}// 这里的 NODE_GYP_MODULE_NAME 宏就是 前面 binding.gyp 的指定的 target_nameNODE_MODULE(NODE_GYP_MODULE_NAME, Init)} // namespace demo
我们写了 2 个版本方便后面对比下,导出函数分别是 fib 和 fib2,再编译后对比下:
const { fib: fibAddon, fib2: fibAddon2 } = require('./build/Release/fib.node');function fib(num) {if (num <= 2) {return 1;} else {let result, a = 1, b = 1;while (num-- > 2) {result = a + b;a = b;b = result;}return result;}}const t1 = process.hrtime.bigint();console.log(fibAddon(64));const t2 = process.hrtime.bigint();console.log(fibAddon2(64));const t3 = process.hrtime.bigint();console.log(fib(64));console.log(`fib: ${t2 - t1}ns, fib2: ${t3 - t2}ns, js: ${process.hrtime.bigint() - t3}ns`);
执行结果:
node-addon-playground(main✗)» node ./2-fib/index.js [12:38:35]106102098577231061020985772310610209857723fib: 6591401ns, fib2: 143046ns, js: 206489ns
看起来 fib2 的执行速度正常多了,比 JS 版本要快了 30% 的样子,当然多次调用对比会有差异但总体来说 addon 版本还是比 JS 版本单次调用会快得多,要精准的 diff 我们需要用到 benchmark 才行,这里我们先跳过。
你也可以再试试对比下把 JS 版本的也改成与 0 比较而不是 2,看看会有性能提升吗?答案是没有明显区别。可以猜想 V8 引擎内部已经做过一些优化了。
结论
所以透过这个例子我们可以看出,C++ Addon 在密集计算的场景相比 JS 是有明显优势的,但前提是,你的 C++ 的代码质量过关,本身不能有性能问题,否则写出来的代码未必比 JS 引擎的 JIT 能带来更高的性能。
