Unity 2022.2 におけるジョブシステムのパフォーマンススケーリングの改善 - パート 2:オーバーヘッド

2022.2 と 2021.3.14f1 では、Unity ジョブシステムのスケジュールコストとパフォーマンススケーリングが改善されました。このジョブシステムの更新に関する 2 パートに渡る記事のパート 1 では、並列プログラミングについての背景情報、およびジョブシステムを使用する理由についてご説明しました。パート 2 では、ジョブシステムのオーバーヘッドとは何かと、それらを軽減するための Unity のアプローチを深掘りしていきます。
オーバーヘッドとは、ジョブのスケジュールを開始した瞬間から、ジョブの実行が終了して待機しているジョブのブロックを解除するまでの間に、CPU がジョブを実行せずに費やす時間のことです。大きく分けると、時間が費やされる領域は 2 つあります。
目次C# Job API レイヤー
2.ネイティブジョブスケジューラ(スケジュールされたすべての C# のジョブ、そして内部的には C++ のジョブを管理および実行するもの)
C# Job API の目的は、ネイティブジョブシステムにアクセスする安全な手段を提供することです。これは C# から C++ への遷移のためのバインディングレイヤーであると同時に、ジョブ内から NativeContainers にアクセスする際に、条件の競合やデッドロックに陥る C# ジョブの偶発的なスケジュールを防止するためのレイヤーでもあります。
さらに、この分離によって、ジョブそのものを生み出すよりリッチな方法も提供されます。C++ のレイヤーにおいては、ジョブはいくつかのデータへのポインターと関数ポインターにすぎません。しかし、C# API を利用すると、スケジュールするジョブの種類をカスタマイズし、ユーザー固有のユースケースに合わせてジョブデータをどのように分割し、並列化するかをより高度に制御できます。
ジョブをスケジュールする際、C# のジョブのバインディングレイヤーは、ジョブ構造体をアンマネージメモリ割り当てにコピーします。これにより、ジョブ依存関係やプラットフォーム上の全体的な負荷に影響される C# のジョブ構造体の寿命と、ジョブシステム内のジョブの寿命を切り離すことができます。ジョブシステムは次に、Editor の再生モードのビルドにおいて条件付きで安全チェックを行い、ジョブが安全に実行できることを確認します。
これらのステップは重要ですが、無償ではありませんし、ジョブシステムのオーバーヘッドの一因にもなります。ジョブの大きさは、NativeContainers や依存関係の数と同様にさまざまであるため、ジョブをコピーしてその安全性を検証するためのコストは一定ではありません。このため、Unity 側がコストを小さく抑え、線形計算の複雑さを制限することが重要です。
2021.2 TECH ストリームでは、エンジニアリングチームが個々のジョブハンドルの安全チェック結果をキャッシュすることで、ジョブの安全システムを大幅に改善しました。安全システムは、ジョブ依存関係のチェーン全体と、すべてのジョブが含む各ネイティブメモリのリファレンスを理解し、どのジョブで依存関係の情報が欠けている可能性があるのか、どのジョブに依存関係を追加すべきかを把握する必要があるため、これは特に重要です。このため、スケジュール時に繰り返し実行する項目が非線形に増える可能性があります(つまり、各ジョブとその依存関係について、ジョブが参照する各 NativeContainer と、 その NativeContainer を参照するすべてのジョブの読み取り/書き込み権限をチェックします)。
しかし、Unity は、C# のジョブが一度に 1 つしかスケジュールされないことを利用し、このスケジュールが行われている最中に安全性をチェックできます。スケジュールごとにすべてのジョブを再スキャンする代わりに、ジョブ依存関係チェーンを再検証する必要があるかどうかを迅速に判断し、大量の処理をスキップすることができます。小規模なジョブ依存関係チェーンであっても、こうすることでジョブの安全チェックのコストを劇的に削減することができます。理想的には、開発時にジョブの安全チェックをオフにする理由はないはずです(プレイヤー/シッピングビルドではジョブの安全チェックはオンになっていません)。
C# や C++ のジョブの実行は、ジョブスケジューラを通してスケジュールされます。スケジューラには以下の役割があります。
- ジョブハンドルを通してジョブを追跡する
- ジョブ依存関係を管理し、すべての依存関係が完了してからジョブが実行されるようにする
- ジョブを実行するスレッドである「ワーカースレッド」を管理する
- ジョブが可能な限り迅速に、つまり依存関係が許す限り並列して実行されるようにする
また、C# Job API ではメインスレッドからしかジョブをスケジュールできませんが、ジョブスケジューラでは複数のスレッドで同時にジョブがスケジュールされることを可能にする必要があります。なぜなら、基盤となる Unity エンジンがジョブをスケジュールするスレッドを多く使用しているため、ジョブ内からジョブをスケジュールすることもできるからです。この機能には長所も短所もありますが、正しさをより精査する必要があり、ジョブスケジューラがスレッドセーフでなければならないという要件も追加されます。
2017.3 リリースにおいて、ジョブスケジューラの基本的な外観は以下の通りでした。
- ジョブ用のキュー
- ジョブ用のスタック
- セマフォ
- ワーカースレッドの配列
典型的な使い方のパターンとして、ジョブがスケジュールされると、まず、グローバルでロックフリーの、複数のプロデューサーやコンシューマーを持つキューにエンキューされます。これはワーカースレッドによって処理される準備が整ったジョブを表しています。その後、メインスレッドがセマフォを使って信号を送り、ワーカースレッドを起動させます。
起動されるワーカースレッドの数は、スケジュールされるジョブの種類に依存します。IJob のような単一のジョブは、複数のワーカースレッドに処理を分散させないため、1 つのワーカースレッドだけを起動させます。しかし、IJobParallelFor のジョブは、並列して実行できる複数の処理を表しています。1 つのジョブがスケジュールされていても、多くの処理があり、いくつか、あるいはすべてのワーカースレッドが同時に手伝う必要があるかもしれません。そのため、スケジューラは、使える可能性のあるワーカースレッドの数を把握し、その数を起動させます。
ワーカースレッドが起動されると、そこでジョブが実際に処理されます。2017.3 では、ジョブキューからジョブをデキューし、関連するジョブ依存関係がすべて完了していることを確認する役割を担っていました。それらがまだ完了していない場合、ジョブと未完了の依存関係は、キューの先頭にジャンプして再実行を試みるために、ロックフリーのスタックに追加されます。ワーカースレッドは、エンジンがシャットダウンしたい旨の信号を送るか、スタックとキューにジョブがなくなるまで、これをループで実行します。信号が届くかジョブがなくなった時点で、ワーカースレッドはメインスレッドのセマフォからの信号を待ってスリープに入ります。
while(!scheduler.isQuitting)
{
// Usually empty unless we need to prioritize a dependency
// to unblock a job we got from the queue. Alternatively
// pieces of work from a IJobParallelFor job can end up here to let
// many workers help finish IJobParallelFor work quickly
Job* pJob = m_stack.pop();
if(!pJob)
Job* pJob = m_queue.dequeue();
if(pJob) {
// ExecuteJob if all dependencies are complete, otherwise
// push this job and the dependencies to the stack and try again
if(EnsureDependenciesAreCompleteOtherwiseAddToStack(pJob))
ExecuteJob(pJob);
}
else
{
// Put the thread to sleep until more jobs are scheduled
m_semaphore.Wait(1);
}
}
ジョブスケジューラーは、デフォルトで CPU 上の仮想コアの数より 1 少ない数だけ、ワーカースレッドを作成します。ここでの意図は、各ワーカースレッドをそれぞれの CPU コアで実行しつつ、メインスレッドが実行し続けられるように 1 つの CPU コアを空けておくことです。実際には、コアがゲーム以外のプロセスのために確保されていないプラットフォームでは、オペレーティングシステムやドライバースレッドで行われる計算がゲームのメインスレッドやジョブワーカースレッドと競合しないように、ワーカースレッドの数を減らした方がよい場合があります。
メインスレッドはジョブのスケジュールが行われる主な場所なので、メインスレッドを遅延させないことは非常に重要です。メインスレッドの遅延は、ジョブシステムに入るジョブの数に直接影響し、フレーム内でどれだけの並列処理が可能になるかを決定します。
理論上、メインスレッドが多くのジョブをスケジュールし、残りの CPU コアがそれらのジョブを実行することで、CPU 上で実行できる並列処理の量を最大化し、ハードウェアの変更に応じて性能をスケールさせることが可能になるはずです。ワーカースレッドがコア数より多い場合、オペレーティングシステムはメインスレッドのコンテキストスイッチを行い、ワーカースレッドに切り替えることができるでしょう。追加のワーカースレッドを稼働させれば、より早くジョブキューを空にできるかもしれませんが、新しい処理がキューに入るのを妨げるため、結果的にパフォーマンスに大きな悪影響を及ぼします。
ジョブスケジューラの上記のアプローチには、ジョブシステムのオーバーヘッドにつながりかねない問題があります。いくつかの例を見てみましょう。
メインスレッドが依存関係のない IJob(非並列ジョブ)をスケジュールした場合:
- ジョブがキューに追加され、ワーカースレッドを起動させる信号が送られる
- ワーカースレッドが起動する
- ワーカースレッドがジョブを実行する
- ワーカースレッドが他に実行するジョブがないか確認する
- 他にジョブがないため、ワーカースレッドがスリープモードに入る
メインスレッドがジョブスケジューラのセマフォを使って信号を送ると、スリープしているワーカースレッドのうち 1 つ(必ずしもワーカー 0 ではない)が起動します。ワーカースレッドのコアが起動し、コンテキストスイッチを行うには時間がかかります。なぜなら、ワーカースレッドがスリープしている間、ワーカースレッドが実行される CPU コアは、ゲームに呼び出された別のスレッドや、スレッドを使用していたマシン上の他のプロセスを実行していた可能性が高いからです。
スレッドを一時停止し、後で再開できるようにするには、スレッドの登録状態を保存し、命令パイプラインをフラッシュし、切り替え先のスレッドの状態を復元する必要があります。どのスレッドを起動させるかの通知は、オペレーティングシステムによって処理されるため、メインスレッドのコアでは、スレッドに信号を送ることさえ時間がかかります。つまるところ、メインスレッドとワーカースレッドのコアで不必要な処理が行われていて、これが削減すべきオーバーヘッドとなっているのです。

ワーカースレッドへの通知の速度や、個々のジョブの実行にかかる時間も、システムに影響を与える可能性があります。例えば、上記の使用例で、1 つではなく 2 つのジョブをスケジュールするとします。
- ジョブがキューに追加され、ワーカースレッドを起動させる信号が送られる
- 2 つ目のジョブがキューに追加され、ワーカースレッドを起動させる信号が送られる
- 同じ手順が順不同で 2 回繰り返される
- ワーカースレッドが起動する
- ワーカースレッドがジョブを実行する
- ワーカースレッドが他に実行するジョブがないか確認する
- 他にジョブがないため、ワーカースレッドがスリープモードに入る
タイミングが合えば、2 つのワーカースレッドが並列してジョブを実行することになります。

しかし、片方のジョブが小さすぎたり、両方のワーカースレッドに信号を送ったり起動したりするのに時間がかかりすぎたりすると、片方のワーカースレッドがキュー内のすべてのジョブを実行する可能性があり、1 つのワーカースレッドは無意味に信号を受け取ったことになってしまいます。

このようなジョブの枯渇と起動 ↔ スリープのサイクルは、最終的にコストが非常にかさばる可能性があり、ジョブシステムが提供できる並列性の量も制限されます。
「スレッドへの信号の送信やコンテキストスイッチによるオーバーヘッドが存在するのは、そもそもスレッドを扱うのであれば仕方がないことなのではないか」と思うかもしれません。これは決して間違いではありません。しかし、スレッドへの信号の送信や起動にどれだけのコストがかかるかを直接制御することはできなくとも、それらの処理の頻度を制御することは可能です。
理由もなくワーカースレッドを起動させるのを避ける 1 つの解決策は、ワーカースレッドで行うべきジョブが、ワーカースレッドの起動コストを正当化できるほど多くキューに入っていると思われる時のみ、ワーカースレッドを起動させることです。これはバッチ処理を通して実現可能です。ジョブをスケジュールした直後にワーカースレッドに信号を送るのではなく、ジョブをリストに追加し、特定の時間にジョブのバッチをジョブシステムにフラッシュし、同時に適切な数のワーカースレッドを起動させるのです。

実際の起動に時間がかかりすぎたり、バッチ化されたジョブが非常に少なかったり、バッチ内のジョブ数があまり多くなかったりするリスクは健在です。一般的に、バッチに含めるジョブが多ければ多いほど、スレッドを無意味に起動させることによるオーバーヘッドを回避できる可能性は高くなります。Unity には、JobHandle.Complete() が呼び出されるたびにフラッシュされるグローバルバッチがあります。そのため、明示的にジョブの完了を待つ必要がある場合、できるだけ遅く、頻度も少なくするようにしてください。また、一般的には、データへの安全なアクセスを最適に制御するために、ジョブ依存関係を使用してジョブをスケジュールすることが好ましいです。
「スレッドに信号を送ったり、スレッドの起動やスリープを待ったりするのがオーバーヘッド以外の何者でもないとしたら、スレッドを常に起動させたままジョブを探させてはどうか」と思うかもしれません。実のところ、これはキューにたくさんのジョブがある場合は自然に発生します。オペレーティングシステムが、ワーカースレッドの優先度が他の処理よりも低いと判断しない限り(または、プラットフォームによっては、明示的にタイムスライスされていて、他のスレッドに CPU の使用時間を公平に割り当てるためにスワップされるべきであると判断しない限り)、ワーカースレッドはずっと処理を続けます。
しかし、パート 1 で登場した PartialUpdateA 関数と PartialUpdateB 関数と同様、すべてのジョブが並列化可能でデータ依存性がないとは限りません。そのため、通常は他のジョブを実行する前に、ジョブのサブセットが完了するのを待たなくてはいけません。結果として、実行可能なジョブ(未解決の依存関係がないジョブ)の数がワーカースレッドの数よりも少なくなり、一部のワーカースレッドが生産的なジョブを与えられなくなる状況が発生すると、ジョブグラフの並列性にボトルネックが発生します。
ワーカースレッドをスリープさせずに稼働を続けさせると、いくつかの問題が発生します。ワーカースレッドが常に新しいジョブの有無をチェックしても何も見つからない場合、これは「ビジーウェイティング」、つまりプログラムを進行させない無駄な処理とみなされます。すべてのコアを最大限の並列処理で動かし続けても、ゲームを進行させなければバッテリーを消耗するだけです。さらに、コアにアイドル状態の時間がないと、十分な冷却がない限り CPU の温度が上昇し、ダウンクロック(過熱によるダメージを避けるために動作を遅くすること)につながります。実際、モバイルプラットフォームでは、CPU コアが熱くなりすぎると、CPU コア全体が一時的に使用不可になることも珍しくありません。ジョブシステムにとって、コアを効率的に使えるかどうかは非常に重要なことなので、ワーカースレッドをスリープ状態にすることと、新しいジョブに備えて常にループさせることとのバランスを取る必要があります。
上記の設計においてオーバーヘッドを発生させる可能性があるもう 1 つの領域として、ロックフリーのキューとスタックが挙げられます。これらのデータ構造を実装することの微妙なニュアンスについては割愛しますが、ロックフリーの実装に共通する特徴のひとつとして、コンペアアンドスワップ(CAS)ループの使用があります。ロックフリーのアルゴリズムは、ロック同期プリミティブを使用して共有状態への安全なアクセスを提供するのではなく、アトミック命令を使用して、アイテムをスレッドセーフな方法でキューに挿入するような高度なアトミック操作を丁寧に作成します。しかし、直感的ではないかもしれませんが、ロックフリーのアルゴリズムは他のスレッドが完了するまで、特定のスレッドの進行を依然として妨げます。また、CPU の命令パイプラインやメモリパイプラインに二次的影響を及ぼし、パフォーマンススケーリングに支障をきたすこともあります。(「ウェイトフリー」のアルゴリズムは、すべてのスレッドが常に進行することを可能にしますが、実際には、それによって全体的なパフォーマンスが常に最善になるわけではありません)。
以下の人為的な例では、CAS ループを使ってメンバ変数 m_Sum に数値を追加しています。
int Add(int val)
{
int newSum;
do
{
// Load the current value we want to update
var oldSum = m_Sum;
// Compute new value we want to store
newSum = oldSum + val;
// Attempt to write the new value. CompareExchange returns
// the value seen inside m_Sum when writing newSum to m_Sum.
// If newSum doesn't match oldSum, we will retry the loop
// since it means another thread wrote to the memory before us.
// If we wrote our value without this check, we might
// write an incorrect value
}while (oldSum != Interlocked.CompareExchange(ref m_Sum, newSum, oldSum));
return newSum ;
}
CAS ループは、「2 つの値が等しいかどうかを比較し、等しければ、最初の値を置き換える」コンペアアンドスワップ命令(ここでは C# Interlocked ライブラリを使用し、プラットフォームの仕様を抽象化している)に依存しています。Add() 関数のユーザーがこの関数が失敗する可能性を危惧せずに済むよう、ループが使用されることで、他のスレッドが m_Sum の更新を先に行ったために失敗した場合には再試行が行われます。
この再試行ループは、要するに「ビジーウェイト」ループです。これはパフォーマンススケーリングにとっては厄介なことを意味します。複数のスレッドが同時に CAS ループに入った場合、一度にループから出られるのは 1 つだけで、各スレッドが実行している処理は直列化されます。幸いなことに、CAS ループが行う処理は通常は意図的に少なく抑えられていますが、それでもパフォーマンスに大きな悪影響を及ぼすことがあります。より多くのコアが並列してループを実行すると、スレッドが競合している間、各スレッドがループを完了するのにより時間がかかります。
さらに、CAS ループは共有メモリへのアトミックな読み書きに依存しているため、通常は各スレッドでイテレーションごとにキャッシュラインが無効にされる必要があり、さらなるオーバーヘッドを引き起こします。このオーバーヘッドは、CAS ループ内の計算のやり直し(上記の例の場合、2 つの数値を足し合わせる処理のやり直し)にかかるコストに比べ、非常にコストがかさむ可能性があります。そのため、一見しただけではコストの高さがわからないこともあります。
2017.3 のジョブスケジューラでは、ジョブを実行していないワーカースレッドは、共有されたロックフリーのスタックかキューでジョブを探していました。これらのデータ構造はどちらも、データ構造からジョブを取り除くために少なくとも 1 つの CAS ループを使っていました。よって、利用可能なコアが増えるにつれて、データ構造に競合があった場合、スタックやキューから処理を引き受けるコストが増加していたのです。特にジョブが小さい場合、ワーカースレッドは比例してキューやスタックで処理を探す時間が長くなっていました。
ある小さなプロジェクトで、典型的なゲームがフレーム更新に使うような、決定論的なジョブグラフを生成しました。以下のグラフは、単一ジョブと並列ジョブ(それぞれが 1~100 の並列ジョブに並列化される)で構成されています。各ジョブには 0~10 のジョブ依存関係があり、メインスレッドには、次のジョブをスケジュールする前に特定のジョブの終了を待たなければならない明示的な同期ポイントが時々ありました。ジョブグラフにおいて 500 個のジョブを生成し、それぞれに一定時間の実行時間をかけると(並列ジョブの各部分にもこの時間がかかる)、使用するコアが増えるにつれてジョブシステムのオーバーヘッドが増えることがわかります。

0.5 マイクロ秒かかるジョブの場合、ワーカースレッドが 20 個になると、フレームの更新スピードはジョブシステムをまったく使わない場合と同じになり、マシンのコアをすべて使った場合は 2 倍近く遅くなります。Unity ではデフォルトですべてのコアが使用されるため、1 マイクロ秒のジョブに関しては、31 個のワーカースレッドを使用していたにもかかわらず、パフォーマンスの向上はほとんどありませんでした。これは、ロックフリーのキューとスタックにおける高い競合が直接的な結果として現れたものです。幸い、ユーザージョブはサイズが大きくなる傾向にあるため、このオーバーヘッドが目立つことはありません。しかし、スケーリングの問題は健在で、小さなジョブは(特に並列ジョブにおいて)依然としてありふれています。より大きなジョブを使用する場合でも、スケジュールのパターンやワーカースレッドのタイミングによって、ジョブスケジューラ内のグローバルでロックフリーのスタックやキューとの競合による大量のオーバーヘッドが発生する可能性があります。
ここまでで、Unity 側とゲームクリエイター側の両方で、ジョブシステムのオーバーヘッドを削減するために対処する必要があった領域がいくつかあったことをご理解いただけたと思います。
- メインスレッドにおけるストール回避:
- ワーカースレッドを起動させるための信号は高コストなので、最低限に抑える必要があります。
- ワーカースレッドと共有されているメインスレッドでステートを変更すると、キャッシュが無効になったり、ビジーウェイトが発生したりする可能性があります。
- Complete() するジョブを明示的に待つことを避けるため、メインスレッドはジョブを頻繁にスケジュールする必要があります。依存関係のあるジョブの投入を優先してください。
- ワーカースレッドにおけるストール回避:
- ワーカースレッドの効率は並列性に直接影響します。共有リソースでの競合は可能な限り避けましょう。
- ワーカースレッドでのビジーウェイトは、温度上昇によるバッテリー寿命の消耗やダウンクロックの原因となります。
Unity は、ユーザーがゲームに投入するジョブの数を変更することはできません。しかし、当社のエンジニアが、異なるジョブスケジューラのアプローチをもって対処できる問題はそれなりにあります。2022.2 リリースにおいて、ジョブスケジューラは、高いレベルでいくつかの基本的なコンポーネントに分かれています。
- ワーカースレッドの配列
- ジョブ用キューの配列
- セマフォの配列
これは以前のジョブスケジューラにとてもよく似ています。主な違いは、メインスレッドとワーカースレッドの共有状態がなくなったことです。その代わりに、キューとセマフォ(サポートされているプラットフォームでは futex)をワーカースレッドごとにローカルにしています。こうすることで、メインスレッドがジョブをスケジュールすると、グローバルキューではなくメインスレッドのキューにエンキューされます。
同様に、ワーカースレッドがジョブをスケジュールする必要がある場合(ジョブがその Execute でジョブをスケジュールする場合など)、そのジョブはメインスレッドのキューではなく、ワーカースレッド自身のキューにスケジュールされます。これにより、ワーカースレッドがキューに書き込む際にキャッシュラインを無効にする頻度が減るため、メモリトラフィックを軽減できます。そのため、ワーカースレッドはすべてのキューに対して同じ頻度で読み書きするわけではありません。
取り扱うキューが増えた今、ワーカーループも変化しました。
while(!scheduler.isQuitting)
{
// Take a job from our worker thread’s local queue
Job* pJob = m_worker_queue[m_workerId].dequeue();
// If our queue is empty try to steal work from someone
// else's queue to help them out.
if(pJob == nullptr) {
pJob = StealFromOtherQueues()
}
if(pJob) {
// If we found work, there may be more conditionally
// wake up other workers as necessary
WakeWorkers();
ExecuteJob(pJob);
}
// Conditionally go to sleep (perhaps we were told there is a
// parallel job we can help with)
else if(ShouldSleep())
{
// Put the thread to sleep until more jobs are scheduled
m_semaphores[m_workerId].Wait(1);
}
}
ワーカースレッドは自身のキューにある処理を探し、自身のキューが空になったときだけ、他のワーカースレッドのキューを確認します。ワーカースレッドは、デキューやエンキューを行う際に自身のキューを優先するため、1 つのキューでの競合の量は減少します。
もう 1 つの違いは、スレッドの起動を知らせる信号です。ワーカースレッドには、他のワーカースレッドを起動させる責任が与えられ、メインスレッドには、ジョブをスケジュールする際に少なくとも 1 つのワーカースレッドが起動していることを確認する責任が与えられました。
この責任の変更により、メインスレッドは、並列ジョブが投入されたときにスレッドを起動させることに単独で責任を負う必要がなくなるため、過剰なオーバーヘッドを取り除くことができます。その代わり、ジョブシステムは、ワーカースレッドを起動させる必要があるかを知るために追跡を行います。メインスレッドは、ワーカースレッドが常に起動していて、ジョブを進行できる状態であることを確認します。そして、起動して自身のキューや他のキューでジョブを見つけた場合、ワーカースレッドは他のワーカースレッドに、起動してキューを空にするよう信号を送ることができます。


また、ワーカースレッドのキューを分けることで、設定や最適化の自由度が上がります。Unity チームはこの分野にさらなる改良を加えていく予定です。2022.2 では、ワーカースレッド起動時にメインスレッドにかかるコストが削減され、プラットフォームのコア数に関係なく、ワーカースレッド上のジョブのスループットが向上することが期待できます。また、Unity はキューの分離を 2021.3 LTS にバックポートしていませんが、メインスレッド単独ではなく、ワーカースレッドにも互いに信号を送る責任を持たせる設計変更を再導入しました。2021.3.14f1 をもって、グローバルセマフォへの信号送信に起因する、メインスレッドにおけるジョブシステムの高いオーバーヘッドは解決できているでしょう。
ご質問がある場合、またはさらなる詳細を知りたい場合は、C# Job System フォーラムをご覧ください。Unity Discord から、私(ユーザー名:@Antifreeze#2763)に直接ご連絡いただくことも可能です。現在連載中の Tech from the Trenches シリーズの他の Unity 開発者による新しい技術ブログもぜひご覧ください。