TT Lab
はじめる
学ぶ 学習パス コース

遅くなったのは一つではなく全部だった

大きな計算を刻み、ワーカーへ移す

TT Labで続きを見る

目標

ループを長く握る計算を、2つの方法で扱います。断片に刻んで合間に順番を譲る方法と、ワーカースレッドへ丸ごと移す方法です。そしてそれぞれのコストを数字で測ります。

なぜ重要なのか

「重ければワーカーに送れ」という助言は、半分しか言っていません。ワーカーはメモリを共有しないので、いまのリクエストのオブジェクトをそのまま見ることはできず、スレッドを1つ立ち上げるのにも時間がかかります。その時間が仕事そのものより大きければ、移すのは損です。

刻む方法はその逆です。同じスレッドに残っているので状態をそのまま見られ、その代わり、断片の間に譲るコストを総時間として支払います。そして断片1つの時間がそのまま遅延の下限になります。断片を回している間は誰も割り込めないからです。

2つのコストを測っておけば、「この仕事をどこに置くか」は好みではなく計算になります。最後のステップで、その計算をルールとして固めます。

ステップ

  1. /root/work/worker/split.mjsにchunks(total, size)を作ります。
  2. 同じファイルにhashRange(from, to, seed)とrunSliced(total, sliceSize)を作ります。
  3. 一度に回す側と刻んだ側を測り、/root/work/worker/report.jsonの runs.whole・runs.slicedに書きます。
  4. 2つの結果からslicing.costRatio・tailRatio・sliceMsを計算して書きます。
  5. /root/work/worker/hash-worker.mjsを作り、runInWorker(total)で移します。
  6. ワーカーが動いている間を測ってruns.workerに書き、worker.startupMsも書きます。
  7. place(job)で「どこに置くか」をルールにします。

参考

境界で取りこぼさないように分ける

/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という境界は、前のステップで測った立ち上げコストから出てきました。別のマシンでは別の値が出て、そうなるとこの定数も変わる必要があります。ルールは測定に付随するものです。