🔬

Node.js で時間がかかる処理を N-API 化してみて速さを実感する

2022/10/10に公開

はじめに

こんにちは、わたる です。

以前記載した拙記事 Node.jsが単一のプロセスで動作していることを身体で理解する において、時間がかかる処理は「n番目のフィボナッチ数を求める関数」として実装したのですが、参考サイトとして記載した Node.js メインスレッドで重い処理を行う では JavaScript で速く処理を行いたい場合の実装も書いてありました。

最近、C++ で生成したアドオンを使ってネイティブ拡張を使って速くする、ということを知りまして、C/C++ 畑の経験が多い自分にとって、それがどれくらいの効果があるのか?を調べたくて実験してみました。

環境

  • OS : Ubuntu 18.04 LTS
  • Node.js : v16.17.1
  • npm : 8.15.0

ネイティブ拡張を作る

下記のページを参考にして作りました。
N-APIとnode-addon-apiでNode.jsのネイティブ拡張を作る

フィボナッチ数を求める関数を C++ のネイティブ拡張で実装し、ワーカースレッドで実行する箇所からこの拡張をコールするイメージです。

ファイル構成

ファイル構成は下記になります。

napi_sample
 ┣━ binding.gyp
 ┣━ package.json
 ┣━ server.js
 ┣━ source
 ┃ ┣━ CAddOn.cc
 ┃ ┗━ CAddOn.h
 ┗━ worker.js

プロジェクトの生成

npm コマンドにより作成します。

$ npm init

package name: には今回は「napi_sample」を入れて Enter、versiondescriptions はそのまま Enter を押し、entry point は今回は「server.js」を入れて Enter、その他はそのまま Enter にして package.json を作成します。

その後、エディタで package.json を以下のように修正します。

package.json
{
  "name": "addon_sample",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "install": "node-gyp rebuild",
    "start": "node server.js"
  },
  "author": "",
  "license": "MIT",
  "gypfile": true,
  "dependencies": {
	  "bindings": "^1.5.0",
	  "node-addon-api": "^5.0.0"
  }
}

binding.gyp にビルド設定を記載する

napi のビルドは binding.gyp というファイルに必要な情報を記載して用意します。
今回はこのように記載しました。

binding.gyp
{
    "targets": [
        {
            "target_name": "addon",
            "sources": [ "source/CAddOn.cc" ],
            "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ],
            "include_dirs": [ "<!@(node -p \"require( 'node-addon-api' ).include\")" ],
        }
    ]
}

今回は簡単なプログラムですので C++ の例外を作らないためdefinesの定義にNAPI_DISABLE_CPP_EXCEPTIONSを追加しました。

アドオンのクラスの実装

JavaScript から呼び出せるようにするため、Napi::ObjectWrapper を利用して C++ のオブジェクトを JavaScript から操作できるようになります。

クラスの宣言のヘッダとして以下のように実装。

CAddOn.h
#ifndef __CADD_ON_H
#define __CADD_ON_H

#include <napi.h>

// アドオンクラス
class CAddOn : public Napi::ObjectWrap<CAddOn>
{
private:
    // JavaScript コンストラクタインスタンス
    static Napi::FunctionReference s_constructor;
    // JavaScript からコールされる関数
    Napi::Value _call_fib( const Napi::CallbackInfo& info ) ;

public:
    // コンストラクタ・デストラクタ
    CAddOn(const Napi::CallbackInfo& info) ;
    virtual ~CAddOn() {}
    // 初期化関数
    static Napi::Object _init( const Napi::Env env, Napi::Object exports ) ;
} ;

#endif

クラス本体は下記のように行いました。

CAddOn.cc
#include <iostream>
#include <napi.h>
#include "CAddOn.h"

// JavaScript のコンストラクタインスタンス
// static 宣言しているため実体の定義が必要
Napi::FunctionReference CAddOn::s_constructor;

extern "C" {
// n番目のフィボナッチ数を求める関数
uint64_t fib(int32_t n)
{
    if (n <= 1) {
        return n ;
    } else {
        return fib(n - 1) + fib(n - 2) ;
    }
}
} ;

// コンストラクタ
CAddOn::CAddOn(const Napi::CallbackInfo& info) : Napi::ObjectWrap<CAddOn>(info)
{
    std::cout << "CAddOn::constructor" << std::endl ;
}

// 初期化関数(JavaScript で new するとコールされる)
Napi::Object CAddOn::_init(const Napi::Env env, Napi::Object exports)
{
    std::cout << "_init" << std::endl ;

    // JavaScript からコールできるよう、関数を定義
    // 「addon.call_fib()」といった形で実装することでコール可能
    Napi::Function func = DefineClass(env, "CAddOn", {
        InstanceMethod("call_fib", &CAddOn::_call_fib),
    }) ;
    s_constructor = Napi::Persistent(func) ;
    s_constructor.SuppressDestruct();
    exports.Set("CAddOn", func) ;

    return exports ;
}

// _init で定義した関数の実体
Napi::Value CAddOn::_call_fib(const Napi::CallbackInfo& info)
{
    std::cout << "_call_fib" << std::endl ;

    // 引数(数値32bit)を定義
    int32_t _arg = info[0].As<Napi::Number>().Int32Value() ;
    // 関数コール
    uint64_t ret = fib(_arg) ;
    // 戻り値を JavaScript の数値で渡せるよう変換
    Napi::Number answer = Napi::Number::New(info.Env(), ret) ;

    return answer ;
}

// アドオンモジュールの初期化
Napi::Object Init(Napi::Env env, Napi::Object exports)
{
    std::cout << "Init." << std::endl ;
    return CAddOn::_init(env, exports) ;
}
// 初期化関数を登録
// ※クラスメンバ関数を直接渡せないのでグローバル関数⇒クラスの static 関数の順で呼ぶ
NODE_API_MODULE(addon, Init)

動作確認

ビルドは npm install にて行います。

$ npm install

> addon_sample@1.0.0 install
> node-gyp rebuild

gyp info it worked if it ends with ok
中略
gyp info spawn args [ 'BUILDTYPE=Release', '-C', 'build' ]
make: ディレクトリ '.../napi_sample/build' に入ります
  CXX(target) Release/obj.target/addon/source/CAddOn.o
  SOLINK_MODULE(target) Release/obj.target/addon.node
  COPY Release/addon.node
make: ディレクトリ '.../napi_sample/build' から出ます
gyp info ok

up to date, audited 4 packages in 6s

found 0 vulnerabilities

ビルドが出来ましたら、実行です。

$ npm start

> addon_sample@1.0.0 start
> node server.js

Start. port on 55555.

と出るとサーバーが立ち上がりますので、別のシェルから

$ curl http://localhost:55555/start?number=40

とやって実行すると、ターミナルは下記のような表示が出ます。

サーバー側のターミナル
start API.
Init.
_init
CAddOn::constructor
_call_fib
start : 2022-10-10 21:16:29
end : 2022-10-10 21:16:34
result : 102334155
lap time : 5422 msec.
クライアント側のターミナル
start : 2022-10-10 21:16:29
end : 2022-10-10 21:16:34
result : 102334155
lap time : 5422 msec.

約 5.4 秒で実行できました。
JavaScript だけの場合は約 12.4 秒かかっていたので倍以上の成果ですね。
※以下、ご参考までに JavaScript でのターミナル表示です。

JavaScript だけのターミナル表示
start : 2022-09-30 23:04:51
end : 2022-09-30 23:05:04
result : 102334155
lap time : 12369 msec.

まとめ

今回はネイティブ拡張によりどれだけ処理時間が改善するか確認してみました。
JavaScript だけだとやはり遅いので、例えば I/F の部分は Node.js のままにして内部の処理を C++ のネイティブ拡張して改善、という手もよいかもしれません。

最後に、今回の環境を github にアップいたしましたのでご確認ください。
https://github.com/wattak777/napi_sample

以上です。

Discussion