.. _sec_parameterserver: パラメータサーバー ================== 単一GPUから複数GPUへ、さらに複数GPUを含む複数サーバーへと移行し、それらが複数のラックやネットワークスイッチにまたがって配置される可能性があるにつれて、 分散・並列学習のためのアルゴリズムは、より高度なものにする必要がある。詳細は重要である。というのも、相互接続方式によって帯域幅は大きく異なるからである(たとえば、NVLink は適切な設定では 6 本のリンクを通じて最大 100 GB/s を提供でき、PCIe 4.0(16レーン)は 32 GB/s を提供する。一方、高速な 100GbE Ethernet でさえ 10 GB/s にすぎない)。同時に、統計モデリングの専門家にネットワークやシステムの専門家であることを期待するのは無理がある。 パラメータサーバーの中核となるアイデアは、分散潜在変数モデルの文脈で :cite:t:`Smola.Narayanamurthy.2010` により導入された。その後、push と pull の意味論について :cite:t:`Ahmed.Aly.Gonzalez.ea.2012` で説明され、システムとオープンソースライブラリについて :cite:t:`Li.Andersen.Park.ea.2014` で説明された。以下では、効率化に必要な構成要素を動機づける。 データ並列学習 -------------- 分散学習におけるデータ並列学習のアプローチを見直しよう。この節では、他のすべての方法を除外してこれを用いる。というのも、実際の実装がかなり単純だからである。GPU には現在十分なメモリがあるため、(グラフ上の深層学習を除けば)他の並列化戦略が好まれる実用例はほとんどない。 :numref:`fig_parameterserver` は、 :numref:`sec_multi_gpu` で実装したデータ並列性の変種を示している。その要点は、更新されたパラメータをすべての GPU に再配布する前に、勾配の集約を 1 枚の GPU(GPU 0)上で行うことである。 .. _fig_parameterserver: .. figure:: ../img/ps.svg 左: 単一GPUでの学習。右: マルチGPU学習の変種。(1) 損失と勾配を計算し、(2) すべての勾配を1枚のGPU上で集約し、(3) パラメータ更新を行い、パラメータをすべてのGPUに再配布する。 振り返ってみると、GPU 0 上で集約するという判断はかなり場当たり的に見える。結局のところ、CPU 上で集約してもよいはずである。実際、あるパラメータは 1 枚の GPU 上で、別のパラメータは別の GPU 上で集約することさえできる。最適化アルゴリズムがそれをサポートしている限り、そうできない理由は特にない。たとえば、勾配 :math:`\mathbf{g}_1, \ldots, \mathbf{g}_4` に対応する 4 つのパラメータベクトルがあるなら、それぞれの :math:`\mathbf{g}_i`\ (\ :math:`i = 1, \ldots, 4`\ )について、勾配を 1 枚の GPU 上で集約できる。 この考え方は恣意的で軽率に見えるかもしれない。確かに、数学的にはどこでも同じである。しかし、 :numref:`sec_hardware` で述べたように、私たちは帯域幅の異なるバスを持つ現実の物理ハードウェアを扱っている。 :numref:`fig_bw_hierarchy` に示すような実際の 4-way GPU サーバーを考えてみよう。特に接続が良ければ、100 GbE のネットワークカードを備えているかもしれない。より一般的なのは 1–10 GbE の範囲で、有効帯域幅は 100 MB/s から 1 GB/s 程度です。 CPU にはすべての GPU に直接接続するのに十分な PCIe レーンがないため(たとえば、コンシューマ向け Intel CPU では 24 レーンです)、\ `マルチプレクサ `__ が必要になる。CPU から 16x Gen3 リンクでの帯域幅は 16 GB/s である。これは、各 GPU がスイッチに接続される速度でもある。つまり、デバイス間で通信するほうがより効率的なのである。 .. _fig_bw_hierarchy: .. figure:: ../img/bw-hierarchy.svg 4-way GPU サーバー。 議論のために、勾配のサイズが 160 MB だと仮定しよう。この場合、残りの 3 枚の GPU から 4 枚目の GPU に勾配を送るのに 30 ms かかります(各転送は 10 ms = 160 MB / 16 GB/s)。さらに重みベクトルを送り返すのに 30 ms かかるので、合計 60 ms になる。 すべてのデータを CPU に送ると、4 枚の GPU *それぞれ* が CPU にデータを送る必要があるため 40 ms の追加コストが発生し、合計 80 ms になる。最後に、勾配を 40 MB ずつ 4 つに分割できると仮定しよう。すると、PCIe スイッチはすべてのリンク間でフル帯域幅の通信を提供するので、各部分を別々の GPU 上で *同時に* 集約できる。30 ms の代わりに 7.5 ms で済み、同期操作全体は 15 ms になる。要するに、パラメータをどのように同期するかによって、同じ操作が 15 ms から 80 ms まで変わり得るのである。 :numref:`fig_ps_distributed` は、パラメータ交換のさまざまな戦略を示している。 .. _fig_ps_distributed: .. figure:: ../img/ps-distributed.svg パラメータ同期戦略。 性能向上のために利用できる別の手段があることにも注意しよう。深いネットワークでは、上から下までのすべての勾配を計算するのにある程度の時間がかかりる。したがって、他のパラメータ群の勾配を計算している最中であっても、いくつかのパラメータ群については同期を開始できる。これを Horovod でどのように行うかについては、たとえば :cite:t:`Sergeev.Del-Balso.2018` を参照しよ。 リング同期 ---------- 現代の深層学習ハードウェアで同期を行う際には、しばしばかなり特殊なネットワーク接続に遭遇する。たとえば、AWS p3.16xlarge と NVIDIA DGX-2 のインスタンスは :numref:`fig_nvlink` の接続構造を共有している。各 GPU は PCIe リンクを介してホスト CPU に接続されており、その速度は最大でも 16 GB/s である。さらに各 GPU には 6 本の NVLink 接続があり、それぞれが双方向で 300 Gbit/s の転送能力を持つ。これは 1 リンク・1 方向あたり約 18 GB/s に相当する。要するに、NVLink の総帯域幅は PCIe の帯域幅よりかなり大きいのである。問題は、それを最も効率的に使う方法である。 .. _fig_nvlink: .. figure:: ../img/nvlink.svg 8枚の V100 GPU サーバーにおける NVLink 接続(画像提供: NVIDIA)。 最適な同期戦略は、ネットワークを 2 つのリングに分解し、それらを使ってデータを直接同期することだと分かっている :cite:`Wang.Li.Liberty.ea.2018`\ 。 :numref:`fig_nvlink_twoloop` は、ネットワークが 1 つのリング(1-2-3-4-5-6-7-8-1、NVLink 帯域幅が 2 倍)と、もう 1 つのリング(1-4-6-3-5-8-2-7-1、通常の帯域幅)に分解できることを示している。この場合に効率的な同期プロトコルを設計するのは容易ではない。 .. _fig_nvlink_twoloop: .. figure:: ../img/nvlink-twoloop.svg NVLink ネットワークの2つのリングへの分解。 次の思考実験を考えてみよう。\ :math:`n` 個の計算ノード(または GPU)からなるリングがあるとする。最初のノードから 2 番目のノードへ勾配を送ることができる。そこでそれはローカル勾配に加えられ、3 番目のノードへ送られ、以下同様に続く。\ :math:`n-1` ステップ後には、集約された勾配は最後に訪れたノードに存在する。つまり、勾配の集約にかかる時間はノード数に対して線形に増加する。しかし、この方法ではアルゴリズムはかなり非効率である。結局のところ、どの時点でも通信しているノードは 1 つしかない。もし勾配を :math:`n` 個のチャンクに分割し、チャンク :math:`i` の同期をノード :math:`i` から開始したらどうだろうか。 各チャンクのサイズは :math:`1/n` なので、総時間は :math:`(n-1)/n \approx 1` になる。言い換えると、勾配の集約に費やす時間は、リングのサイズを大きくしても *増加しない* のである。これは非常に驚くべき結果である。 :numref:`fig_ringsync` は、\ :math:`n=4` ノードでの手順を示している。 .. _fig_ringsync: .. figure:: ../img/ringsync.svg 4ノード間のリング同期。各ノードは、組み上がった勾配が右隣のノードに現れるまで、勾配の一部を左隣へ送り始める。 8 枚の V100 GPU 間で 160 MB を同期する同じ例を用いると、およそ :math:`2 \cdot 160 \textrm{MB} / (3 \cdot 18 \textrm{GB/s}) \approx 6 \textrm{ms}` になる。これは、8 枚の GPU を使っているにもかかわらず PCIe バスを使うよりも優れている。実際には、深層学習フレームワークが通信を大きなバースト転送にまとめることに失敗することが多いため、これらの数値は少し悪くなる。 リング同期が他の同期アルゴリズムと本質的に異なるという誤解がよくあるが、注意しよう。違いは、単純な木構造と比べて同期経路がやや複雑であるという点だけである。 マルチマシン学習 ---------------- 複数マシンでの分散学習には、さらに別の課題がある。すなわち、場合によっては 1 桁以上遅い、比較的低帯域幅のファブリックを介して接続されたサーバーと通信する必要があるのである。 デバイス間の同期は厄介である。結局のところ、学習コードを実行している異なるマシンは、微妙に異なる速度で動作する。したがって、同期的な分散最適化を使いたいなら、それらを *同期* させる必要がある。 :numref:`fig_ps_multimachine` は、分散並列学習がどのように行われるかを示している。 1. 各マシンで(異なる)バッチのデータを読み込み、それを複数の GPU に分割して GPU メモリへ転送する。そこで各 GPU バッチごとに予測と勾配を別々に計算する。 2. すべてのローカル GPU からの勾配を 1 枚の GPU 上で集約する(あるいは、その一部を異なる GPU 上で集約する)。 3. 勾配を CPU に送る。 4. CPU は勾配を中央のパラメータサーバーに送り、そこで全勾配を集約する。 5. 集約された勾配を用いてパラメータを更新し、更新後のパラメータを各 CPU にブロードキャストし返す。 6. その情報を 1 枚(または複数枚)の GPU に送る。 7. 更新されたパラメータをすべての GPU に展開する。 .. _fig_ps_multimachine: .. figure:: ../img/ps-multimachine.svg マルチマシン・マルチGPU分散並列学習。 これらの操作はどれもかなり単純に見える。実際、単一マシン *内* では効率的に実行できる。しかし複数マシンになると、中央のパラメータサーバーがボトルネックになることが分かりる。結局のところ、サーバーごとの帯域幅には限りがあるため、\ :math:`m` 個のワーカーに対してすべての勾配をサーバーへ送るのにかかる時間は :math:`\mathcal{O}(m)` である。この壁を破るには、サーバー数を :math:`n` に増やせばよいだろう。このとき各サーバーはパラメータの :math:`\mathcal{O}(1/n)` しか保持する必要がないため、更新と最適化にかかる総時間は :math:`\mathcal{O}(m/n)` になる。 両者を対応させれば、扱うワーカー数に関係なく一定のスケーリングが得られる。実際には、\ *同じ* マシンをワーカーとしてもサーバーとしても使う。 :numref:`fig_ps_multips` はその設計を示している(詳細は :cite:`Li.Andersen.Park.ea.2014` も参照されたい)。 特に、複数マシンが不合理な遅延なく動作するようにするのは容易ではない。 .. _fig_ps_multips: .. figure:: ../img/ps-multips.svg 上: 単一のパラメータサーバーは帯域幅が有限なのでボトルネックになる。下: 複数のパラメータサーバーが、総帯域幅を持つパラメータの一部を保持する。 キー–バリューストア ------------------- 分散マルチGPU学習に必要な手順を実際に実装するのは容易ではない。 そのため、共通の抽象化、すなわち更新の意味論を再定義した *キー–バリューストア* を使うと有益である。 多くのワーカーと多くの GPU にまたがって、勾配 :math:`i` の計算は次のように定義できる。 .. math:: \mathbf{g}_{i} = \sum_{k \in \textrm{workers}} \sum_{j \in \textrm{GPUs}} \mathbf{g}_{ijk}, ここで :math:`\mathbf{g}_{ijk}` は、ワーカー :math:`k` の GPU :math:`j` 上で分割された勾配 :math:`i` の一部である。 この操作の要点は、これが *可換な縮約* であることである。つまり、多くのベクトルを 1 つにまとめ、どの順序で操作を適用しても結果が変わらない。これは私たちの目的にとって都合がよい性質である。というのも、どの勾配がいつ受信されるかを細かく制御する必要が(なくても)よいからである。さらに、この操作は異なる :math:`i` の間で独立であることにも注意しよう。 これにより、次の 2 つの操作を定義できる。勾配を蓄積する *push* と、集約された勾配を取得する *pull* である。勾配の集合は多数存在するため(結局のところ、層も多数ある)、勾配をキー :math:`i` で索引付けする必要がある。Dynamo :cite:`DeCandia.Hastorun.Jampani.ea.2007` で導入されたもののようなキー–バリューストアとのこの類似性は偶然ではない。キー–バリューストアもまた、多くの類似した特性を満たしており、特に複数サーバーにパラメータを分散する際にそうです。 キー–バリューストアにおける push と pull の操作は次のように説明される。 - **push(key, value)** は、特定の勾配(value)をワーカーから共通ストレージへ送りる。そこでその値は、たとえば加算によって集約される。 - **pull(key, value)** は、共通ストレージから集約済みの値を取得する。たとえば、すべてのワーカーからの勾配をまとめた後に取得する。 同期に関する複雑さをすべて単純な push と pull の操作の背後に隠すことで、最適化を単純な形で表現したい統計モデラーの関心事と、分散同期に本質的な複雑さを扱わなければならないシステムエンジニアの関心事を分離できる。 まとめ ------ - 同期は、特定のネットワーク基盤やサーバー内の接続性に対して高度に適応的である必要がある。これにより、同期にかかる時間が大きく変わり得る。 - リング同期は p3 および DGX-2 サーバーでは最適になり得るが、他ではそうとは限らない。 - 複数のパラメータサーバーを追加して帯域幅を増やす場合には、階層的な同期戦略がうまく機能する。 演習 ---- 1. リング同期をさらに高速化できるか? ヒント: 両方向にメッセージを送ることができる。 2. 非同期通信を許可することは可能ですか(計算がまだ進行中の間に)? それは性能にどのように影響するか? 3. 長時間実行される計算の途中でサーバーを失ったらどうなるか? 計算を完全にやり直さずに済むような *フォールトトレランス* 機構をどのように設計できるか?