Posted at

Node.jsで大規模演算を並列化する


はじめに

大規模なシステムを作成していると、大規模な演算を必要とすることがあると思います。そのとき、Node.jsではどのように作っていくのが良いのか、気になったため調べ始め、この記事を書くに至りました。

大規模な演算を必要なときにNode.jsを使うなよなどなどあるかもしれませんが、チームの特性上など色々あるかと思うので、ご容赦願います。

素数を求める処理を直列と並列の2パターンで行い、それぞれの実行時間の計測を行います。計測に用いた実コードと計測結果から、並列化を行うかどうかの今後の判断にお使い頂ければと思います。


並列化の手段

並列化の手段として、Node.jsで用意されているのは、『Cluster』と『Worker Threads』です。


Node is designed to build scalable network applications.


Node.jsはWebアプリケーションを作るためにあります。そして、そのWebアプリケーションをスケーラブルにするための機能を担っているのが『Cluster』です。

では、Webアプリケーション以外を並列処理するためにはどうすれば良いでしょうか?

そのための方法こそが『Worker Threads』になります。


Workers are useful for performing CPU-intensive JavaScript operations


公式ドキュメント中で、このようにCPUを使う処理で有効な操作であると説明されています。

この記事では、大規模な演算というところに絞りたいため、『Worker Threads』のみ計測を行います。

注)『Worker Threads』の機能は、試験的機能です。安定版として提供されている機能でない点にはご注意ください。突如仕様が変更されるなどの危険性があります。


計測


計測コード

実際に作成したコードは以下のGitHubリポジトリに上げています。

walk8243/NodeJS-Worker

素数判定の方法が雑ですが、演算量が多い単純な処理をさせたかっただけですので、気にしないで頂けると幸いです。


並列処理


thread.ts

import { Worker } from "worker_threads";

import Main from "./thread/Main";

const loop = 4;
const workerData = {
maxNumber: 100000,
};
const main = new Main(loop);

for(let i=0; i<loop; i++) {
const worker = new Worker(require.resolve('./thread/worker'), { workerData: workerData });
worker.on('message', (message) => {
main.emit('result', worker.threadId, message.result);
});
}



thread/Main.ts

import { EventEmitter } from "events";

export default class Main extends EventEmitter {
public obj: { id: number, result: number[] }[] = [];
private counter: number = 0;

constructor(private finish = 4) {
super();

this.on('result', (threadId: number, result: number[]) => {
this.obj.push({ id: threadId, result: result });
});
this.on('result', () => {
if(++this.counter == this.finish) {
console.log(this.obj.map((value) => value.result.length));
}
});
}
}



thread/worker.ts

import { threadId, parentPort, workerData } from "worker_threads";

console.log('worker thread', threadId);
const naturalNumber = [];
const maxNumber = workerData.maxNumber || 10000;
target: for(let i=2; i<maxNumber; i++) {
check: for(let j=2; j<i; j++) {
if(i % j == 0) {
continue target;
}
}
naturalNumber.push(i);
}

const data = { result: naturalNumber };

parentPort ? parentPort.postMessage(data) : console.log(data);


# 実行方法

# 実行時 --experimental-worker が必須です
yarn run tsc
node --experimental-worker thread.js


直列処理


normal.ts

const data = [];

const loop = 4;
const maxNumber = 100000;

for(let i=0; i<loop; i++) {
const naturalNumber = [];
target: for(let i=2; i<maxNumber; i++) {
check: for(let j=2; j<i; j++) {
if(i % j == 0) {
continue target;
}
}
naturalNumber.push(i);
}

data.push({ result: naturalNumber });
}

// console.log(data);
console.log(data.map((value) => value.result.length));



結果

計測は以下の環境で行った。

項目

CPU
Intel(R) Core(TM) i7-7Y75 CPU @ 1.30GHz 1.60GHz

コア数
2

スレッド数
4

OS
Windows 10 Home 17134.885


パターン1[10万までの素数,4回演算]

直列
並列

1回目
7.168s
3.836s

2回目
7.466s
3.904s

3回目
8.084s
3.849s

4回目
8.654s
3.963s

5回目
7.698s
3.961s

6回目
7.590s
4.072s

7回目
7.767s
4.040s

8回目
7.839s
4.078s

9回目
7.592s
4.072s

10回目
7.655s
3.830s

平均値
7.751s
3.961s


パターン2[10万までの素数,2回演算]

直列
並列

1回目
3.859s
2.617s

2回目
3.949s
2.518s

3回目
3.920s
2.671s

4回目
3.871s
2.637s

5回目
4.039s
2.706s

6回目
3.950s
2.655s

7回目
3.987s
2.608s

8回目
3.938s
2.566s

9回目
4.090s
2.742s

10回目
3.824s
2.656s

平均値
3.943s
2.638s


パターン3[10万までの素数,1回演算]

直列
並列

1回目
1.983s
1.963s

2回目
2.305s
1.955s

3回目
1.919s
1.984s

4回目
2.066s
1.910s

5回目
1.960s
1.947s

6回目
1.821s
2.116s

7回目
1.881s
2.042s

8回目
1.960s
1.989s

9回目
1.883s
1.997s

10回目
1.870s
2.128s

平均値
1.965s
2.003s


パターン4[5万までの素数,4回演算]

直列
並列

1回目
2.330s
1.155s

2回目
2.072s
1.163s

3回目
2.044s
1.183s

4回目
2.048s
1.179s

5回目
1.987s
1.165s

6回目
1.963s
1.128s

7回目
2.567s
1.144s

8回目
2.521s
1.185s

9回目
2.800s
1.147s

10回目
2.531s
1.164s

平均値
2.286s
1.161s


パターン5[5万までの素数,2回演算]

直列
並列

1回目
1.154s
0.842s

2回目
1.149s
0.871s

3回目
1.107s
0.797s

4回目
1.101s
0.779s

5回目
1.128s
0.874s

6回目
1.123s
0.877s

7回目
1.049s
0.764s

8回目
1.123s
0.852s

9回目
1.118s
0.833s

10回目
1.078s
0.870s

平均値
1.113s
0.836s


パターン6[1万までの素数,4回演算]

直列
並列

1回目
0.243s
0.304s

2回目
0.213s
0.280s

3回目
0.227s
0.297s

4回目
0.219s
0.326s

5回目
0.219s
0.306s

6回目
0.208s
0.311s

7回目
0.226s
0.297s

8回目
0.240s
0.294s

9回目
0.216s
0.322s

10回目
0.202s
0.301s

平均値
0.221s
0.304s


パターン7[1万までの素数,2回演算]

直列
並列

1回目
0.174s
0.230s

2回目
0.185s
0.254s

3回目
0.176s
0.246s

4回目
0.185s
0.223s

5回目
0.164s
0.219s

6回目
0.172s
0.255s

7回目
0.212s
0.249s

8回目
0.172s
0.230s

9回目
0.206s
0.243s

10回目
0.163s
0.281s

平均値
0.181s
0.243s


パターン8[10万までの素数,8回演算]

CPUのスレッド数を超えるケースとして、追加計測しました。

直列
並列

1回目
15.382s
8.289s

2回目
14.982s
8.294s

3回目
15.264s
8.277s

4回目
16.475s
7.940s

5回目
15.564s
7.822s

平均値
15.533s
8.124s


さいごに

総括すると、『Worker Threads』を使うことで、有意義なレベルで実行時間の短縮ができました。

演算量の多い場合に関しては、直列でも並列でも実行時間に差はほとんどありませんでした。

一方で、演算量の少ない場合は、実行回数を上げても直列の方が早い結果となりました。

『Worker Threads』で並列処理を行い、演算結果をメインスレッドに返したい場合、Workerの管理が必要となってきます。手間がかかる部分なので、実行時間のことも踏まえて、全てを並列処理にするように考えるのではなく、演算量と相談をして決める必要があります。

シングルスレッドを言い訳にNodeが遅いなんて言わせない世界を目指しましょう!