Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

はじめに

大規模なシステムを作成していると、大規模な演算を必要とすることがあると思います。そのとき、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が遅いなんて言わせない世界を目指しましょう!

walk8243
ただの乃木オタです。 業務中に感じた良く分からない気持ち悪さを、形にして解決するにはどうしたらいいかを考えながら記事を書いてます。 執筆は握手会の会場でできるからいいですね!
yahoo-japan-corp
Yahoo! JAPAN を運営しています。
https://www.yahoo.co.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away