大きな計算を刻み、ワーカーへ移す
目標
ループを長く握る計算を、2つの方法で扱います。断片に刻んで合間に順番を譲る方法と、ワーカースレッドへ丸ごと移す方法です。そしてそれぞれのコストを数字で測ります。
なぜ重要なのか
「重ければワーカーに送れ」という助言は、半分しか言っていません。ワーカーはメモリを共有しないので、いまのリクエストのオブジェクトをそのまま見ることはできず、スレッドを1つ立ち上げるのにも時間がかかります。その時間が仕事そのものより大きければ、移すのは損です。
刻む方法はその逆です。同じスレッドに残っているので状態をそのまま見られ、その代わり、断片の間に譲るコストを総時間として支払います。そして断片1つの時間がそのまま遅延の下限になります。断片を回している間は誰も割り込めないからです。
2つのコストを測っておけば、「この仕事をどこに置くか」は好みではなく計算になります。最後のステップで、その計算をルールとして固めます。
ステップ
/root/work/worker/split.mjsにchunks(total, size)を作ります。- 同じファイルに
hashRange(from, to, seed)とrunSliced(total, sliceSize)を作ります。 - 一度に回す側と刻んだ側を測り、
/root/work/worker/report.jsonのruns.whole・runs.slicedに書きます。 - 2つの結果から
slicing.costRatio・tailRatio・sliceMsを計算して書きます。 /root/work/worker/hash-worker.mjsを作り、runInWorker(total)で移します。- ワーカーが動いている間を測って
runs.workerに書き、worker.startupMsも書きます。 place(job)で「どこに置くか」をルールにします。
参考
chunks(total, size)は{from, to}オブジェクトの配列です。sizeが0以下なら例外を投げてください。 そのままにすると永遠に回る繰り返しになります。hashRange(from, to, seed = 0)は、h = (h * 31 + i) >>> 0をfromからtoの手前まで回した値です。 順序に依存する計算なので、断片の間で前の値をseedとして渡さないと答えが同じになりません。runSliced(total, sliceSize)は{result, slices}を返します。runInWorker(total)はhashRange(0, total)と同じ値をPromiseで返し、spawnCostMs()は空の仕事1つで起動時間だけを測ります。place(job)のルール:cpuMsが1以下なら"loop"、それより大きくsharedStateが真なら"slice"、そうでなければcpuMsが50以上のとき"worker"で、50未満は"slice"です。cpuMsがないか数値でなければ例外を投げてください。- ワーカーを立ち上げるプログラムを
node -eで動かさないでください。ワーカーが親の実行 引数を引き継ぎ、ERR_INPUT_TYPE_NOT_ALLOWEDで落ちます。測るプログラムも ファイルに置き、node measure.mjsのように動かします。
境界で取りこぼさないように分ける
/root/work/worker/split.mjsにchunks(total, size)をexportしてください。{from, to}の配列で、最後の断片は短くてもかまいません。sizeが0以下なら例外を投げます。
toはその位置を含みません。そうすれば、次の断片のfromとそのままつながります。
断片の数はMath.ceil(total / size)で、つなぎ合わせると、隙間も重なりもなく0からtotalまでを覆わなければなりません。
刻んで、間に順番を譲る
同じファイルにhashRange(from, to, seed = 0)とrunSliced(total, sliceSize)をexportしてください。runSlicedは{result, slices}を返し、断片の間ごとにループへ1回譲ります。
h = (h * 31 + i) >>> 0を回しますが、前の断片の結果を次の断片のseedとして渡してください。渡さないと答えが変わります。
譲るのはawait new Promise(r => setImmediate(r))の1行です。分けるだけでこの行がなければ、ループから見ると1回で回したのと同じです。採点ツールは、その間にタイマーが何回動いたかを見ます。
同じ答え、違う分布
4000万回回る計算を、(ア)一度に、(イ)断片に分けて、それぞれ測り、/root/work/worker/report.jsonにnode・totalとruns.whole・runs.slicedを書いてください。どちらにもsamples・p50・p99・max・workMs・resultを入れ、runs.slicedにはslicesも入れます。
測る物差しは、2つ目のラボのwithLagと同じ形のものを、このファイルにも1つ置けば十分です。
resultは2つで同じでなければなりません。違うなら、断片の間でseedを渡していません。刻んでも答えは変わりません。
刻むことのコストを計算する
slicingにcostRatio(sliced.workMs / whole.workMs、小数第3位)、tailRatio(whole.max / sliced.max、小数第2位)、sliceMs(sliced.workMs / sliced.slices、小数第3位)を書いてください。
costRatioは1より少し大きくなります。譲るたびにループを1周させるコストです。
sliceMsが重要です。断片1つを回している間は誰も割り込めないので、目標の遅延を決めれば断片のサイズが決まります。 50ms以内に応答したいなら、断片1つが50msを超えてはいけません。
別のスレッドへ移す
/root/work/worker/hash-worker.mjsを作り(parentPortで結果を送り返します)、split.mjsにrunInWorker(total)をexportしてください。hashRange(0, total)と同じ値をPromiseで返し、同時に2回呼んでも動く必要があります。
ワーカーはメモリを共有しません。必要な値はworkerDataで渡し、結果はpostMessageで受け取ります。
そのため、「いまのリクエストのオブジェクトをワーカーで書き換えてほしい」というのはできません。コピーして送り、コピーして受け取るだけです。移せる仕事と移せない仕事は、ここで分かれます。
ワーカーのコストを計算する
ワーカーが動いている間を測り、runs.workerにsamples・p50・p99・max・workMs・resultを書き、spawnCostMs()をexportしてその値をworker.startupMsに書いてください。
ワーカーが動いている間、このスレッドの遅延はほぼ0にとどまります。計算がここにないからです。その代わりにstartupMsを見てください。
立ち上げコストが仕事そのものより大きければ、移すのは損です。そのため実際のサービスでは、ワーカーをあらかじめ何個か立ち上げておいて再利用します。この数字がその理由です。
ルールとして固める
place(job)をexportしてください。job.cpuMsが1以下なら"loop"、それより大きくjob.sharedStateが真なら"slice"、そうでなければcpuMsが50以上のとき"worker"で、50未満は"slice"です。cpuMsがないか数値でなければ例外を投げます。
数値のない仕事に適当な答えを返すのが最も危険です。「測っていない」と「軽い」は違います。
50という境界は、前のステップで測った立ち上げコストから出てきました。別のマシンでは別の値が出て、そうなるとこの定数も変わる必要があります。ルールは測定に付随するものです。