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](https://github.com/walk8243/NodeJS-Worker)

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

### 並列処理
```index.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);
```

```.sh
# 実行方法
# 実行時 --experimental-worker が必須です
yarn run tsc
node --experimental-worker index.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が遅いなんて言わせない世界を目指しましょう！