PLAID Engineer Blog

PLAID Engineer Blog


PLAID Engineer Blog

MicrosoftのNapa.jsでJavaScriptをマルチスレッド化する

Yuki MakinoYuki Makino

こんにちは、プレイドの @makinoy です。 久しぶりのBlog投稿です。書こうとして止めていた記事はひとまず置いといて今回新しく一つ記事を書くことにしました。 テーマはNapa.jsを使ってJavaScriptでmulti-threadingをやってみる、です。

背景

フロントエンドからバックエンドまで含めて考えると、KARTEのコードの大部分はJavaScriptで書かれています。その理由の大半は、特にフロントエンドよりの話が多く、Angular.js, React.js, Vue.jsに代表されるWebフレームワークの著しい発展や、V8のパフォーマンスの進化、Node.jsやNPMなどその周辺のモジュールのエコシステムの充実が大きいと思います。

KARTE的には、スキーマレスな構造データを扱えることが特徴の一つなので、その周辺の操作に関しては動的言語が適しているというポイントもあります。それに、加えアーキテクチャ的な特徴から、解析処理とWeb上でのアクションがワンセットになっているということもあり、バックエンドでもフロントエンド実行されるデータの中身を評価する必要があったりします。そういう理由から、かなりCPUインテンシブな処理をJavaScriptで書き、その上で動しているの多いのが特徴です。(一部のコードはJavaやPythonを使っています。)

バックエンドのコードはNode.jsを使ってJavaScriptを実行しています。Node.jsは1 workerに対して1プロセスを割り当て、1CPUでイベントループを回すことによって処理します。(詳しくはこちら https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/) I/Oインテンシブな処理が多いWebサーバーなどの場合、workerに対してスレッド割り当てるよりは、I/O待ちのプロセスに新規リクエストを処理させることで、コンテクストスイッチが少なくて高速です。

ですが、KARTEのように、Webのリクエスト処理中に解析処理も行うアプリケーションにとってみると、1 worker - 1 CPUの制約が少々ボトルネックになってくるという課題があります。基本的には、Node.jsのworkerを増やせばスループット的には問題ないと思います。ただ、KARTEの0.x秒で解析処理を終わらせリクエストを処理するということを考えると、安定的に低レスポンスで処理しなればなりません。そうすると、どこかのCPU処理がたまたま多くリソースを使うと、その処理の影響が同じworkerの処理にも影響が出てしまいます。これを避けるには、自分でCPU処理を分割する、スレッドを使ってOSのスケジューラーに任せるなどの必要があります。あらかじめ自分でCPU処理を分割するのは大変なので、スレッドを使う方が楽です。ですがNode.js上では、JavaScriptのプログラムからスレッドを使うことはできません。

結構悩んでいる問題なので、背景がちょっと長くなってしまいました… つまり、JavaScript(Node.js)でスレッドが必要となるポイントは

  1. JavaScript便利
  2. Node.jsとNPMが便利
  3. でもNode.jsは基本的にI/Oインテンシブな処理に向いてる
  4. CPUインテンシブな処理を効率的にしたいのでスレッドを使いたい

という感じでしょうか。

そんな中、マイクロソフトが、CPUインテンシブな処理をJavaScriptでmulti-threadingを使って実現する仕組みであるNapa.js (https://github.com/Microsoft/napajs) を作ったみたいなので、調べてみました。なぜ名前がNapaなのかは調べてません。Napa valleryのNapa? Nodeっぽいから?

モチベーション

Napa.jsも同様にJavaScriptの柔軟さとNode.jsがバックエンドとして使っているV8のパフォーマンスが高い点に着目して、さらにC++のハイパフォーマンス要素が融合すれば次の段階に進化できることを目指して作られてます。開発者が、C++とJavaScriptの利点をダイナミックにバランスをとっていけることを目指していて、例えば最初はJavaScriptで始め、固まってきたらC++に移していくようなことが気軽にできるになることを目指しています。

開発者のDaiyi Pengは4つのポイントの実現が重要だと言ってます。

  1. アルゴリズム上で素早くやり取り(反復)ができる。
  2. マルチコアの利点を活かす
  3. Workerが構造データを共有メモリでシェア
  4. スレッド間の通信コストを最小にして可能な限り粒度が細かい並列処理を行う

これを現時点でNode.jsで実現しようとすると、JavaScriptのasync関数としてC++のコードを使えるようにするか、Node.jsが持つclusterモジュールを使うかです。一つ目は、1を満たせず。二つ目は、3を満たせません。プロセスが別れてしまうと、データを受け渡すのにヒープメモリを使うことができないので、大変です。clusterモジュールを使う場合は、プロセス間通信を使ってデータをやり取りする必要があるので、worker間のデータのやり取りにコストがかかります。

概要

Napa.jsの設計概念と構成要素を紹介します。

ゾーン (Zone)

すべてのマルチスレッドワーカーは、ポリシー定義したりや実際にJavaScriptを実行するゾーン (zone)と呼ばれるものに属します。1プロセスに対して、複数のJavaScriptワーカーで構成されたゾーンが複数紐付きます。

ゾーンの中ではワーカーは同じコードをロードし、同じbroadcastexecuteリクエスト(後で説明)を受け取り処理します。ゾーン内の一つのワーカーに対して選択的にリクエストを出すことはできません。一方で、異なるゾーンのワーカーどうしは非対称的です。アプリケーションは、複数のゾーンを異なるポリシーや目的のワークロードのために使い分けます。

Napa zoneとNode zone

ゾーンには2タイプあります。Napa zoneNode zoneの二つです。Napa zoneは、切り離されたV8上のNapa.jsが管理するJavaScriptワーカーで構成されるゾーンです。Napa zoneではNode.jsのAPIが制限されています。Node zoneは、Node.jsのイベントループ上の仮想のゾーンで、Node.jsのAPIが全て使えます。Napa zoneは重いタスクをこなし、一方でNode zoneはIO処理やNode.jsのAPIを利用する、という形で互いに補完し合います。

a.png (37.6 kB)

BroadcastとExecute

ゾーン上ではBroadcastとExecuteという二つのオペレーションを実行できます。

Broadcastはすべてのワーカーの状態を変更するコードを走らせます。その実行が成功したか失敗したかをPromise経由で受け取ることができます。起動やあらかじめキャッシュを作っておくなどするときに使います。

Executeはワーカーの状態を変更しないでコードを走らせます。実際にタスクを実行するときはこちらを使用し、Promise経由で計算結果を受け取ることができます。

オブジェクト転送

V8は、複数の分離したメモリ空間でJavaScriptを走らせるように作られていません。なので、ヒープメモリは、それぞれで管理する必要があり、値を別のV8に渡すには、シリアライズ/デシリアライズ(marshall/unmarshall)する必要があります。なので、オブジェクトのペイロードのサイズや複雑性は、通信効率に大きな影響を与えます。

Transportable型

Transportable(トランスポート可能な)な型は、他のV8に渡すことができ、ワーカー間で透過的なJavaScriptの型です。broadcastやexecuteの引数として渡すことができ、ワーカー間の共有オブジェクトにset/getで渡すこともできます。

Transportable型は、1. JavaScriptのプリミティブ、2.Transportableインタフェースを実装したオブジェクト(Typescript)、3.これらのコンポジットでできているArrayとオブジェクト、4. undefinedです。

ワーカー間ストレージ

Store APIはJavaScriptのワーカー間でtransportableな型を共有するために必要なAPIです。store.setするとそのプロセスのヒープにJSONでシリアライズされ保存され、すべてのスレッドはstore.getでシリアライズして受け取ることができます。

参照カウントされているので自動でGCされるようです。Worker間でメモリがシェアされたとしても、GCがある言語上でGCがない部分でデータを自分で管理しようとすると大変です。

メモリ管理

APIとしてnapajsメモリを触れるものも用意してあるようです。ポインタを表すHandle、 Native Objectをワーカー間でシェアできるShareable、メモリを確保するAllocatorが用意されいるようですが、詳細に関してはあまり記述が無いです。

C++モジュール

Node.jsと同様にC++を使ったモジュールを作成できるようになるみたいですが、APIの詳細はまだ固まっていないようです。

試してみる

では実際に試してみましょう。

インストール
npm install napajs  
実行
ゾーンの作成
const napajs = require('napajs');  
const zone = napajs.zone.create('simple-zone', {wokers: 8});  
Nodeゾーンにアクセス
const node_zone = napajs.zone.node;  
boradcastしてexecute
function f() {  
  console.log('hi');
}
// fを定義
zone.broadcast(f.toString());  
// 定義したfを実行
zone.execute(() => { f() });  
// > hi
transportableかチェック
napajs.transport.isTransportable(undefined)  
// > true
// napajs.transport.isTransportable(null)
// TypeError. bug
napajs.transport.isTransportable({a:1})  
// > true
napajs.transport.isTransportable({a:1, b:/regexp/})  
// > false
marshall / unmarshall
var jsonPayload = transport.marshall([1, 'string'], context);  
console.log(jsonPayload);  
// [1,"string"]
napajs.transport.unmarshall(jsonPayload, context);  
// > [ 1, 'string' ]
store
var zone = napajs.zone.create('zone1');  
var store = napajs.store.create('store1');

store.set('key1', {  
    a: 1, 
};

zone.execute(() => {  
    var store = global.napa.store.get('store1'); // globalにnapaが定義されている
    console.log(JSON.stringify(store.get('key1')));
});
// > {"a";1}
フィボナッチ数列を計算する

並列計算の例ではみんな大好きフィボナッチ

var napa = require("napajs");  
var zone = napa.zone.create('zone', { workers: 4 });

function fibonacci(n) {  
    if (n <= 1) {
        return n;
    }

    var p1 = zone.execute("", "fibonacci", [n - 1]);
    var p2 = zone.execute("", "fibonacci", [n - 2]);

    return Promise.all([p1, p2]).then(([result1, result2]) => {
        return result1.value + result2.value;
    });
}

function run(n) {  
    var start = Date.now();

    return zone.execute('', "fibonacci", [n])
        .then(result => {
            console.log(n, result.value, Date.now() - start);
            return result.value;
        });
}

// 初期化
zone.broadcast(' \  
    var napa = require("napajs"); \
    var zone = napa.zone.get("zone"); \
');  
zone.broadcast(fibonacci.toString());

// 実行
run(10)  
.then(() => run(11))
.then(() => run(12))
.then(() => run(13))
.then(() => run(14))
.then(() => run(15))
.then(() => run(16))
.then(() => run(17))
.then(() => run(18))
.then(() => run(19))
.then(() => run(20))
.then(() => run(21))
.then(() => run(22))

上のコードをworkers:1, 2, 4で計算したら、手元のMac( 2.5 GHz Intel Core i7 4 cores)フィボナッチ数22がそれぞれ、3.7 sec, 1.8 sec, 1.3 sec程度で計算ができ、CPUコアを有効に使えてるることがわかりました。かなり粒度が細かい並列実行ですが、オーバーヘッドも少なさそうです。

まとめ

JavaScriptでmulti-threadingするNapa.jsについて、それが(PLAIDにとっても)必要となる背景から、その概要について、まとめてみました。まだ、bugがあったりして生まれたて感があるようですが、threadを使った並列化コードをシンプルに書くことができて非常に使えそうな印象があります。このような技術を使って、データ解析のようなCPUインテンシブな処理もJavaScriptでメンテナンスできるようになると嬉しいですね。

参考

https://github.com/Microsoft/napajs/wiki
https://github.com/Microsoft/napajs/wiki/introduction
https://github.com/Microsoft/napajs/wiki/why-napa.js
https://github.com/Microsoft/napajs/tree/master/examples/tutorial/recursive-fibonacci
https://www.linkedin.com/pulse/napajs-multi-threaded-javascript-runtime-daiyi-peng/

最後に

ウェブ接客プラットフォーム「KARTE」を運営するプレイドでは、KARTEを使ってこんなアプリケーションが作りたい! KARTE自体の開発に興味がある!というエンジニア(インターンも!)を募集しています。 詳しくは弊社採用ページ またはWantedly をご覧ください。 もしくはお気軽に、下記の「話を聞きに行きたい」ボタンを押してください!

Yuki Makino
Author

Yuki Makino

Comments