前面的例子主要还是感受 addon 的功能实现,接下来看一个相对复杂一点点的例子,研究下 addon 对比 JS 的性能,我们以斐波那契数列的实现作为例子。

Addon 实现

按照之前的模样把架子实现下:

  1. #include <node.h>
  2. namespace demo {
  3. using v8::Number;
  4. using v8::Local;
  5. using v8::FunctionCallbackInfo;
  6. using v8::Value;
  7. void Fib(const FunctionCallbackInfo<Value> &args) {
  8. v8::Isolate *isolate = args.GetIsolate();
  9. Local<v8::Context> context = isolate->GetCurrentContext();
  10. // TODO
  11. args.GetReturnValue().Set(Number::New(isolate, result));
  12. }
  13. void Init(Local<v8::Object> exports, Local<v8::Object> module) {
  14. NODE_SET_METHOD(exports, "fib", Fib);
  15. }
  16. // 这里的 NODE_GYP_MODULE_NAME 宏就是 前面 binding.gyp 的指定的 target_name
  17. NODE_MODULE(NODE_GYP_MODULE_NAME, Init)
  18. } // namespace demo

接下来我们填充 TODO 里的内容,斐波那契数列的原理就不多说了,直接上代码吧:

  1. #include <node.h>
  2. namespace demo {
  3. using v8::Number;
  4. using v8::Local;
  5. using v8::FunctionCallbackInfo;
  6. using v8::Value;
  7. void Fib(const FunctionCallbackInfo<Value> &args) {
  8. v8::Isolate *isolate = args.GetIsolate();
  9. Local<v8::Context> context = isolate->GetCurrentContext();
  10. uint64_t num = args[0]->IntegerValue(context).ToChecked();
  11. uint64_t result = 0, a = 1, b = 1;
  12. if (num <= 2) {
  13. result = 1;
  14. } else {
  15. while (num-- > 2) {
  16. result = a + b;
  17. a = b;
  18. b = result;
  19. }
  20. }
  21. args.GetReturnValue().Set(Number::New(isolate, result));
  22. }
  23. ...
  24. } // namespace demo

简单看下代码,14 行获取入参就是一个指定取斐波那契数列的第几项,这里因为是正整数(先不考虑异常输入,关于异常后面再说)用 uint64_t 的类型,即无符号 64 位整数,然后就是一个循环,逐步累加了,代码逻辑看起来没啥毛病,还是和之前一样配置下编译,然后写个 JS 调用。

  1. const { fib } = require('./build/Release/fib.node');
  2. console.log(fib(10));
  3. console.log(fib(64));

测试下:

  1. 55
  2. 10610209857723

对比 JS 版本

接下来写个 JS 版本,也很简单,为了节省时间就不啰嗦验证计算结果与前面的版本一样了。

  1. function fib(num) {
  2. if (num <= 2) {
  3. return 1;
  4. } else {
  5. let result, a = 1, b = 1;
  6. while (num-- > 2) {
  7. result = a + b;
  8. a = b;
  9. b = result;
  10. }
  11. return result;
  12. }
  13. }

然后我们对比下两个实现的性能,由于单次计算耗时太低了用 ms 看可能看不出效果,我们用上 node.js 里提供的高精度的可以到 ns 的计时器来计算下耗时:

  1. const { fib: fibAddon } = require('./build/Release/fib.node');
  2. function fib(num) {
  3. if (num <= 2) {
  4. return 1;
  5. } else {
  6. let result, a = 1, b = 1;
  7. while (num-- > 2) {
  8. result = a + b;
  9. a = b;
  10. b = result;
  11. }
  12. return result;
  13. }
  14. }
  15. const t1 = process.hrtime.bigint();
  16. console.log(fibAddon(64));
  17. const t2 = process.hrtime.bigint();
  18. console.log(fib(64));
  19. console.log(`addon: ${t2 - t1}ns, js: ${process.hrtime.bigint() - t2}ns`);

看看结果:

  1. node-addon-playground(main✗)» node ./2-fib/index2.js [12:33:33]
  2. 10610209857723
  3. 10610209857723
  4. addon: 7349237ns, js: 194593ns

咦?!!!怎么可能呢?JS 执行居然比 addon 快了一个数量级?问题出在哪儿呢?
核心原因其实是出在 while 循环的比较,我们是把次数的 loop 与 2 相比,这样从编码的角度性能是不高的,如果改成 0 在机器码的对比上会快得多,我们改一下代码再对比下:

  1. //
  2. #include <node.h>
  3. namespace demo {
  4. using v8::Number;
  5. using v8::Local;
  6. using v8::FunctionCallbackInfo;
  7. using v8::Value;
  8. void Fib(const FunctionCallbackInfo<Value> &args) {
  9. v8::Isolate *isolate = args.GetIsolate();
  10. Local<v8::Context> context = isolate->GetCurrentContext();
  11. uint64_t num = args[0]->IntegerValue(context).ToChecked();
  12. uint64_t result = 0, a = 1, b = 1;
  13. if (num <= 2) {
  14. result = 1;
  15. } else {
  16. while (num-- > 2) {
  17. result = a + b;
  18. a = b;
  19. b = result;
  20. }
  21. }
  22. args.GetReturnValue().Set(Number::New(isolate, result));
  23. }
  24. void Fib2(const FunctionCallbackInfo<Value> &args) {
  25. v8::Isolate *isolate = args.GetIsolate();
  26. Local<v8::Context> context = isolate->GetCurrentContext();
  27. uint64_t num = args[0]->IntegerValue(context).ToChecked();
  28. uint64_t result = 0, a = 1, b = 1;
  29. if (num <= 2) {
  30. result = 1;
  31. } else {
  32. num -= 2;
  33. while (num-- > 0) {
  34. result = a + b;
  35. a = b;
  36. b = result;
  37. }
  38. }
  39. args.GetReturnValue().Set(Number::New(isolate, result));
  40. }
  41. void Init(Local<v8::Object> exports, Local<v8::Object> module) {
  42. NODE_SET_METHOD(exports, "fib", Fib);
  43. NODE_SET_METHOD(exports, "fib2", Fib2);
  44. }
  45. // 这里的 NODE_GYP_MODULE_NAME 宏就是 前面 binding.gyp 的指定的 target_name
  46. NODE_MODULE(NODE_GYP_MODULE_NAME, Init)
  47. } // namespace demo

我们写了 2 个版本方便后面对比下,导出函数分别是 fibfib2,再编译后对比下:

  1. const { fib: fibAddon, fib2: fibAddon2 } = require('./build/Release/fib.node');
  2. function fib(num) {
  3. if (num <= 2) {
  4. return 1;
  5. } else {
  6. let result, a = 1, b = 1;
  7. while (num-- > 2) {
  8. result = a + b;
  9. a = b;
  10. b = result;
  11. }
  12. return result;
  13. }
  14. }
  15. const t1 = process.hrtime.bigint();
  16. console.log(fibAddon(64));
  17. const t2 = process.hrtime.bigint();
  18. console.log(fibAddon2(64));
  19. const t3 = process.hrtime.bigint();
  20. console.log(fib(64));
  21. console.log(`fib: ${t2 - t1}ns, fib2: ${t3 - t2}ns, js: ${process.hrtime.bigint() - t3}ns`);

执行结果:

  1. node-addon-playground(main✗)» node ./2-fib/index.js [12:38:35]
  2. 10610209857723
  3. 10610209857723
  4. 10610209857723
  5. fib: 6591401ns, fib2: 143046ns, js: 206489ns

看起来 fib2 的执行速度正常多了,比 JS 版本要快了 30% 的样子,当然多次调用对比会有差异但总体来说 addon 版本还是比 JS 版本单次调用会快得多,要精准的 diff 我们需要用到 benchmark 才行,这里我们先跳过。

你也可以再试试对比下把 JS 版本的也改成与 0 比较而不是 2,看看会有性能提升吗?答案是没有明显区别。可以猜想 V8 引擎内部已经做过一些优化了。

结论

所以透过这个例子我们可以看出,C++ Addon 在密集计算的场景相比 JS 是有明显优势的,但前提是,你的 C++ 的代码质量过关,本身不能有性能问题,否则写出来的代码未必比 JS 引擎的 JIT 能带来更高的性能。