前面的例子主要还是感受 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();
// TODO
args.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_name
NODE_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));
测试下:
55
10610209857723
对比 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]
10610209857723
10610209857723
addon: 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_name
NODE_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]
10610209857723
10610209857723
10610209857723
fib: 6591401ns, fib2: 143046ns, js: 206489ns
看起来 fib2 的执行速度正常多了,比 JS 版本要快了 30% 的样子,当然多次调用对比会有差异但总体来说 addon 版本还是比 JS 版本单次调用会快得多,要精准的 diff 我们需要用到 benchmark 才行,这里我们先跳过。
你也可以再试试对比下把 JS 版本的也改成与 0 比较而不是 2,看看会有性能提升吗?答案是没有明显区别。可以猜想 V8 引擎内部已经做过一些优化了。
结论
所以透过这个例子我们可以看出,C++ Addon 在密集计算的场景相比 JS 是有明显优势的,但前提是,你的 C++ 的代码质量过关,本身不能有性能问题,否则写出来的代码未必比 JS 引擎的 JIT 能带来更高的性能。