レヌペンRustのデヌタの同時実行性

過去数週間、私はRustでのデヌタ䞊列化のための実隓ラむブラリであるRayonの曎新に取り組んでいたす。

開発がどのように進んでいるかに非垞に満足しおいるので、私はブログ投皿に来た理由を説明するこずにしたした。
Rayonの目暙は、シリアルコヌドに䞊列凊理を簡単に远加できるようにするこずです。これにより、 forルヌプたたは反埩子を耇数のスレッドで動䜜させるこずができたす。 たずえば、このようなむテレヌタのチェヌンがある堎合

 let total_price = stores.iter() .map(|store| store.compute_price(&list)) .sum() 

次に、通垞の「シリアルむテレヌタ」をレヌペンから「パラレルむテレヌタ」に倉曎するだけで、䜜業を䞊列化できたす。

 let total_price = stores.par_iter() .map(|store| store.compute_price(&list)) .sum() 


もちろん、䞊列凊理を単玔にするだけでは十分ではなく、安党にする必芁もありたす。 Rayonは、APIを䜿甚しおもデヌタの競合が発生しないようにしたす。
この投皿では、Rayonの仕組みに぀いお説明したす。 最初に、基本的なレヌペン join プリミティブに぀いお説明し、次にその実装方法に぀いお説明したす。
Rustの倚くの機胜を組み合わせるこずにより、プログラムの実行䞭に非垞に䜎いオヌバヌヘッドでjoinを実装し぀぀、厳密なセキュリティを確保できるこずに泚意を払いたいず思いたす。 次に、 join基づいお䞊列むテレヌタの抜象化がどのように構築されるかを簡単に説明したす。
ただし、レヌペンはさらに開発䞭であるこずを匷調したいず思いたす。 珟圚の実装は私が望むほど柔軟ではないので、䞊列むテレヌタヌの蚭蚈は、より倚くの、䟋えば反埩しゃれなしを経るこずになるず期埅しおいたす。 さらに、特にパニックの広がりやリ゜ヌスのクリヌンアップなど、正しく凊理されない特殊なケヌスがいく぀かありたす。 それにもかかわらず、Rayonは珟圚、特定のタスクに圹立぀こずがありたす。 あなたも幞せになるこずを願っお、私はずおも幞せです

プラむマリレヌペンプリミティブに参加


投皿の冒頭で、map-reduce操䜜に䞊列むテレヌタヌを䜿甚する䟋を瀺したした。

 let total_price = stores.par_iter() .map(|store| store.compute_price(&list)) .sum() 

ただし、実際には䞊列むテレヌタは、より基本的なプリミティブjoin基づいお構築された単なるラむブラリです。 join䜿甚join非垞に簡単です。 以䞋に瀺すように、2぀のクロヌゞャヌを匕き起こし、それらを䞊列に実行join 可胜性がありたす。 䞡方が完了するずすぐに、結果が返されたす。

 // `do_something`  `do_something_else` **   join(|| do_something(), || do_something_else()) 

ここでの䞻なポむントは、2぀のクロヌゞャヌを朜圚的に䞊行しお実行できるこずです 䞊列スレッドを䜿甚するかどうかの決定は、空きカヌネルがあるかどうかに応じお動的に行われたす。 join䜿甚しお、プログラム内で䞊列凊理が圹立぀堎所joinマヌクし、ラむブラリで実行時にそれを䜿甚するかどうかを決定できるようにjoinずいう考え方です。
朜圚的な䞊行性のアプロヌチは、レヌペンを限られたクロスビヌムフロヌず区別する基本的な考え方です。 クロスビヌムで、2぀の制限されたスレッド間で䜜業を分散する堎合、垞に異なるスレッドで䞊行しお実行されたす。 同時に、Rayonでjoinを呌び出しおも、必ずしも䞊列コヌドが実行されるずは限りたせん。 その結果、よりシンプルなAPIだけでなく、リ゜ヌスをより効率的に䜿甚できたす。 これは、䞊列化が有益になる時期を事前に予枬するこずが非垞に難しいためです。 これには垞にグロヌバルコンテキストの知識が必芁です。たずえば、コンピュヌタヌには無料のカヌネルがあり、珟圚実行されおいる他の䞊列操䜜は䜕ですか 実際、この投皿の䞻な目暙の1぀は、先ほど芋た保蚌された同時実行性ずは察照的に、 Rustのデヌタ同時実行性のラむブラリの基瀎ずしお朜圚的な同時実行性を促進するこずです。
これは、クロスビヌムが提䟛する保蚌された䞊列性のための別個の圹割がないこずは蚀うたでもありたせん。 朜圚的な䞊行性のセマンティクスは、䞊列クロヌゞャヌができるこずにもいく぀かの制限を課したす。 たずえば、チャネルを䜿甚しおjoin 2぀のクロヌゞャ間で通信しようずするず、ほずんどの堎合デッドロックが発生したす。 join 、通垞は順次アルゎリズムで䞊列凊理を䜿甚するためのヒントずしお考える䟡倀がありたす。 時にはこれはあなたが望むものではない-いく぀かのアルゎリズムは最初は䞊列である 。 ただし、 join内からMutex 、 AtomicU32などの型を䜿甚するこずは完党に通垞であるこずに泚意しおください。1぀のクロヌゞャヌが別のクロヌゞャヌを埅機するのをブロックしたくないだけです。

結合䟋䞊列クむック゜ヌト


joinプリミティブは、アルゎリズムを分割しお埁服するのに理想的です 。 これらのアルゎリズムは、䜜業をほが等しい郚分に分割し、再垰的に実行したす。 たずえば、quicksortの䞊列バヌゞョンを実装できたす。

 fn quick_sort<T:PartialOrd+Send>(v: &mut [T]) { if v.len() > 1 { let mid = partition(v); let (lo, hi) = v.split_at_mut(mid); rayon::join(|| quick_sort(lo), || quick_sort(hi)); } } fn partition<T:PartialOrd+Send>(v: &mut [T]) -> usize { // . https://en.wikipedia.org/wiki/Quicksort#Lomuto_partition_scheme } 

実際、このバヌゞョンのクむック゜ヌトずシリアルバヌゞョンの唯䞀の違いは、 rayon::join呌び出すrayon::joinたす。

結合の実装方法盗甚


内郚的に、 join ゞョブ代行受信ず呌ばれる手法を䜿甚しお実装されたす。 私の知る限り、䜜業の傍受はCilkプロゞェクトの䞀郚ずしお最初に導入され、それ以来、かなり暙準的な手法になりたした実際、レヌペン「ビスコヌス」、「シルク」、「シルク」、 -箄Transl。 -Cilkぞのオマヌゞュ。
䞻なアむデアは、 join(a, b)呌び出すたびにjoin(a, b)安党に䞊行しお実行できる2぀のタスクaずbを定矩するこずですが、このための空きスレッドがあるかどうかはただわかりたせん。 珟圚のスレッドが行うこずは、 「スケゞュヌルされたゞョブ」キュヌにbを远加するだけで、その埌bを取埗しおすぐに実行aたす。 同時に、他のアクティブなスレッドのプヌルがありたす通垞、CPUコアごずに1぀のスレッド、たたはそのようなもの。 スレッドの1぀が解攟されるずすぐに、他のスレッドの「蚈画された䜜業」のキュヌに入り、タスクがあれば、フリヌスレッドがそれをキャプチャしおそれ自䜓を実行したす。 したがっお、この堎合、最初のスレッドがa実行でビゞヌでa 、別のスレッドがb実行を開始できたす。
最初のスレッドがa終了aずすぐに、他の誰かがb実行を開始したしたか そうでない堎合、圌は自分でそれを行いたす。 その堎合、別のスレッドが終了するたで埅機する必芁がありたす。 しかし、最初のスレッドが埅機しおいる間、別のスレッドから䜜業を奪い取り、それによっお党䜓の䜜業プロセスの完了に貢献できたす。
Rustのような擬䌌コヌドの圢匏では、 joinは次のようになりたす 実際のコヌドは少し異なりたす。たずえば、各操䜜で結果を埗るこずができたす。

 fn join<A,B>(oper_a: A, oper_b: B) where A: FnOnce() + Send, B: FnOnce() + Send, { //  `oper_b`  ,      : let job = push_onto_local_queue(oper_b); //  `oper_a` : oper_a(); // Check whether anybody stole `oper_b`: if pop_from_local_queue(oper_b) { //  ,  . oper_b(); } else { // ,    . //         : while not_yet_complete(job) { steal_from_others(); } result_b = job.result(); } } 

ゞョブキャプチャを非垞に゚レガントにしおいるのは、CPU負荷ぞの自然な適応です。 ぀たり、すべおのワヌカヌスレッドがビゞヌの堎合、 join(a, b)はすべおのクロヌゞャヌを順番に実行し始めたす぀たりa(); b(); 、これはシリアルコヌドより悪くありたせん。 しかし、フリヌスレッドがある堎合は、䞊列実行になりたす。

パフォヌマンス枬定


レヌペンはただ非垞に若いため、テストプログラムはあたりありたせんただ最適化しおいたせん。 これにもかかわらず、すでに顕著な加速を埗るこずができたすが、このためには、デバッグに少し時間をかける必芁がありたす。 たずえば、クむック゜ヌトの曎新バヌゞョンでは、4コアMacbook Proでの䞊列実行による次の加速が芋られたすしたがっお、4倍の加速が期埅できる最倧倀です。
配列の長さ加速
1K0.95x
32K2.19x
64K3.09x
128K3.52倍
512K3.84x
1024K4.01x

元のバヌゞョンず比范しお行った倉曎- 移行をシヌケンシャルアルゎリズムに远加したした。 䞀番䞋の行は、入力配列が十分に小さい堎合私のコヌドでは5000゚レメント未満、 join呌び出しを拒吊しお、アルゎリズムのシヌケンシャルバヌゞョンに移動するjoinです。 これは、私の䟋のコヌドからわかるように、コヌドを特性ずたったく重耇させるこずなく行うこずができたす。 興味があれば、この蚘事の最埌にある付録でアむデアを説明したす。
いく぀かの最適化の埌、シヌケンシャル実行ぞの移行がそれほど頻繁に必芁ずされないこずを願っおいたすが、高レベルのAPI前述の䞊列むテレヌタヌなどがシヌケンシャル実行ぞの移行も行うこずができるため、垞に考える必芁はありたせん。
いずれにせよ、シヌケンシャル実行に移行しないず、結果はそれほど良くありたせんが、はるかに悪い結果になる可胜性がありたす。
配列の長さ加速
1K0.41x
32K2.05x
64K2.42x
128K2.75x
512K3.02x
1024K3.10x

特に、このバヌゞョンのコヌドは、 䞊列凊理のためにすべおのサブ配列をナニット長たで送信するこずに泚意しおください。 配列が512Kたたは1024Kの長さである堎合、倚くのサブアレむが䜜成されたす。これは倚くのタスクを意味したすが、最倧3.10倍の加速が埗られたす。 コヌドが非垞にうたく実行される理由は、次の郚分で説明するように、 基本的なアプロヌチが正しいためだず思いたす。Rayonはメモリの割り圓おず仮想ディスパッチを回避したす。 それでも、1Kアレむでは0.41xよりも優れたパフォヌマンスを望みたすそしおそれは可胜だず思いたす。

Rust機胜を䜿甚しおオヌバヌヘッドを最小限に抑える


䞊蚘からわかるように、このスキヌムを動䜜可胜にするには、タスクをロヌカルキュヌに配眮するオヌバヌヘッドコストを可胜な限り削枛する必芁がありたす。 最終的に、プロセッサの数はタスクの数よりもはるかに少ないため、ほずんどのタスクはむンタヌセプトされないこずが予想されたす。 Rayon APIは、Rustのいく぀かの機胜を䜿甚しおこのオヌバヌヘッドを削枛するように蚭蚈されおいたす。

䞊蚘からわかるように、投皿タスクのオヌバヌヘッドは非垞に䜎くなっおいたすが、私が望むほどではありたせん。 それらをさらに枛らすにはいく぀かの方法がありたす。


デヌタレヌスの自由


レヌペンはデヌタレヌスからの自由を保蚌するこずを先に述べたした。 これは、再珟するのが難しい奇劙なバグを心配するこずなく、以前のシヌケンシャルコヌドに䞊列性を远加できるこずを意味したす。
心配する必芁がある゚ラヌには2぀のタむプがありたす。 たず、2぀のフォヌルトが同じ可倉状態を䜿甚できるため、1぀のスレッドで行われた倉曎が他のスレッドに圱響を䞎える可胜性がありたす。 たずえば、䞡方のクロヌゞャヌでloパラメヌタヌを指定しおquick_sortを誀っお quick_sortように䞊蚘の䟋を倉曎した堎合、コヌドがコンパむルされないこずを願っおいたす。

 fn quick_sort<T:PartialOrd+Send>(v: &mut [T]) { if v.len() > 1 { let mid = partition(v); let (lo, hi) = v.split_at_mut(mid); rayon::join(|| quick_sort(lo), || quick_sort(lo)); // <-- ! } } 

実際、この゚ラヌが衚瀺されたす。

 test.rs:14:10: 14:27 error: closure requires unique access to `lo` but it is already borrowed [E0500] test.rs:14 || quick_sort(lo)); ^~~~~~~~~~~~~~~~~ 

䞀方のクロヌゞャヌでlo たたはhi を凊理し、もう䞀方のクロヌゞャヌでvを凊理しようずするず、同様の゚ラヌが発生したす。これは䞡方のスラむスず重なりたす。
泚この䟋は人為的なように芋えたすが、実際には、䞊列むテレヌタヌを実装するずきに䞀床蚱可したたたは蚱可する実際のバグであり、これに぀いおは埌で説明したす。 コピヌず貌り付けでこのような間違いを犯すのは非垞に簡単で、Rustがそれらを䞍可胜なむベントに倉え、プログラムのクラッシュによるバグに倉えないこずは非垞に良いこずです。
キャッチできる別の皮類のバグは、 joinのクロヌゞャヌの1぀からのスレッドセヌフでないタむプの䜿甚です。 たずえば、RustはRcず呌ばれる非原子参照カりンタヌを持぀型を提䟛したす。 Rcは非アトミック呜什を䜿甚しお参照カりントを曎新するため、 Rcを異なるスレッド間で分割するこずは安党ではありたせん。 次の䟋のように誰かがやろうずするず、参照カりンタが簡単に䞍正になり、メモリが二重に解攟されるか、さらに悪化する可胜性がありたす。

 fn share_rc<T:PartialOrd+Send>(rc: Rc<i32> { //     `clone`   . //     . //     ! rayon::join(|| something(rc.clone()), || something(rc.clone())); } 

しかし、もちろん、この䟋をコンパむルしようずするず、゚ラヌが発生したす。

 test.rs:14:5: 14:9 error: the trait `core::marker::Sync` is not implemented for the type `alloc::rc::Rc<i32>` [E0277] test.rs:14 rayon::join(|| something(rc.clone()), ^~~~~~~~~~~ test.rs:14:5: 14:9 help: run `rustc --explain E0277` to see a detailed explanation test.rs:14:5: 14:9 note: `alloc::rc::Rc<i32>` cannot be shared between threads safely 

ご芧のずおり、 「メモ」の埌の最埌の投皿で、コンパむラは、異なるスレッド間でRcぞのアクセスを共有できないこずを瀺しおいたす。
join関数がこれらの䞍倉条件の䞡方をサポヌトできるのはどのようなダヌクマゞックですか 実際、答えは驚くほど簡単です。 同じ&mutスラむスを2぀の異なるクロヌゞャヌに転送しようずしたずきに受け取った最初の゚ラヌは、基本型システムRustに由来したす同時に存圚し、同時に同じ&mutアクセスできる2぀のクロヌゞャヌを持぀こずはできたせんカットしたす。 これは、 &mutデヌタぞのアクセスが䞀意である必芁があるためです。぀たり、同じ&mut倀ぞの䞀意のアクセスを持぀2぀のクロヌゞャがある堎合、倀はそれほど䞀意ではありたせん。
実際、これはRust型システムで䜜業する際の最倧の掞察の 1぀でした。それ以前は、シヌケンシャルプログラムず「デヌタレヌス」の「ハンギングポむンタヌ 」はたったく異なる皮類のバグであるず考えおいたしたが、 Hydra単独では、基本的に、䞡方のタむプのバグにぱむリアスずデヌタ倉曎が暪行しおおり、䞡方ずもテニュアシステムず借甚システムを䜿甚しお解決できたす。
では、スレッド間でRcを転送しようずした2番目の゚ラヌはどうでしょうか。 join関数では、䞡方のクロヌゞャヌ匕数がSendタむプを満たす必芁があるために発生したした。 RustのSendは、スレッド間でデヌタを安党に転送できるこずを意味したす。 したがっお、 joinが䞡方のクロヌゞャヌがSendタむプを満たす必芁があるこずを宣蚀するず、 「クロヌゞャヌがアクセスできるデヌタの堎合、あるスレッドから別のスレッドに切り替えおも安党であるはずです。 」

䞊列むテレヌタヌ


投皿の冒頭で、䞊列むテレヌタヌを䜿甚した䟋を瀺したした。

 let total_price = stores.par_iter() .map(|store| store.compute_price(&list)) .sum(); 

しかしそれ以来、私はjoin専念しおいたす。 先ほど蚀ったように、䞊列むテレヌタヌAPIは実際にはjoin 単玔なラッパヌです。 珟時点では、それは䜕よりも濃瞮物のように芋えたす。 しかし、本圓に゚レガントなのは 、䞊行性に関連する安党でないコヌドを必芁ずしないこずです。 ぀たり、䞊列むテレヌタヌAPIは単玔にjoinに基づいお構築され、安党でないコヌドをすべお隠したす。 より正確には、ベクトルの構築時に初期化されおいないメモリの管理に関連する安党でないコヌドはただほずんどありたせん。しかし、このコヌドは䞊列凊理ずは関係ありたせん。同様のコヌドはVec実装にありたす、適切に曞く時間がなかったので。
䞊列むテレヌタの実装の詳现に深く入り蟌みたくありたせん。私の蚈画によるず、それはただ倉曎されるためです。 しかし、高レベルでの考え方は、次の基本的なメ゜ッドを持぀ParallelIteratorがあるずいうこずです。

 pub trait ParallelIterator { type Item; type Shared: Sync; type State: ParallelIteratorState<Shared=Self::Shared, Item=Self::Item> + Send; fn state(self) -> (Self::Shared, Self::State); ... //    ,  `map`  . . } 

この考え方は、メ゜ッドstateがむテレヌタを䞀般的な状態ず個々のスレッドの状態に分割するずいうものです。䞀般的な状態は、すべおのワヌカヌスレッドで朜圚的に利甚できるため、タむプである必芁がありたすSync同時に耇数のスレッドからの共有をサポヌトしたす。個々のスレッドの状態はjoin、ぞの呌び出しごずに分離されるため、タむプにのみ応答するSend必芁がありたす別のスレッドに安党に転送できたす。
タむプ ParallelIteratorStateは、残りの䜜業の䞀郚を衚したすたずえば、凊理甚のサブスラむス。圌には3぀の方法がありたす。

 pub trait ParallelIteratorState: Sized { type Item; type Shared: Sync; fn len(&mut self) -> ParallelLen; fn split_at(self, index: usize) -> (Self, Self); fn for_each<OP>(self, shared: &Self::Shared, op: OP) where OP: FnMut(Self::Item); } 

このメ゜ッドlenは、残っおいる䜜業量のアむデアを提䟛したす。メ゜ッドsplit_atはこの状態を2぀の郚分に分割したす。このメ゜ッドfor_eachは、指定された反埩子からのすべおの倀を凊理したす。したがっお、たずえば、スラむスの䞊列むテレヌタ&[T]は次のこずを行う必芁がありたす。

これらの䞡方の特性を備えおいるため、同じパタヌンに埓っおコレクションの䞊列凊理を実装できたす。䜜業がどれだけ残っおいるかを確認し、倚すぎる堎合は、2぀の郚分に分割したす。それ以倖の堎合は、コレクションを順番に凊理したすこれには、前に芋た順番凊理ぞの移行が自動的に含たれるこずに泚意しおください。

 fn process(shared, state) { if state.len() is too big { //     let midpoint = state.len() / 2; let (state1, state2) = state.split_at(midpoint); rayon::join(|| process(shared, state1), || process(shared, state2)); } else { //     state.for_each(|item| { // process item }) } } 

ここでは、䟋を参照するこずができ、リンクされおいるベクトルの芁玠のコレクションをしお1぀の倀にデヌタ・ストリヌムを最小限に抑えるには。

結論ず歎史的背景


Rayonの最新バヌゞョンに非垞に満足しおいたす。圌女は非垞に䜿いやすく、非垞に衚珟力があり、非垞に効果的になる可胜性が高いず思いたす。
たた、Rustでデヌタの䞊列凊理が゚レガントになったこずも嬉しく思いたす。これは、長い進化ず倚くの開発の繰り返しの結果です。たずえば、Rustは初期の頃、共有メモリを䜿甚せずに䞊列タスクがパむプを介しお通信する厳密なアヌランのようなアプロヌチを䜿甚しおいたした。このアプロヌチは、高レベルのアプリケヌションには適しおいたすが、䞊列クむック゜ヌト゜ヌトの蚘述には適しおいたせん。ただし、型システムを埐々に倉曎しお、パラレルクむック゜ヌトのシンプルでクむックなバヌゞョンにどんどん近づけるようにしたした。
私の初期の デザむンを芋るず、珟圚の反埩Rayonが珟時点で最適であるこずは明らかです。私が特に気に入っおいるのは、ナヌザヌだけでなく開発者にずっおもシンプルなこずです。぀たり、Rust型システムでクレむゞヌなトリックを䜿甚したり、セキュリティを実珟するためにパズル型を䜿甚したりする必芁はありたせん。これは、蚀語の2぀の䞻な゜リュヌションのおかげで可胜だず思いたす。


アプリケヌションコヌドの重耇を䌎​​わない順次実行ぞの移行の実装


クむック゜ヌトの䟋でパフォヌマンスを向䞊させるには、配列が十分に小さい堎合にシリアルコヌドぞのゞャンプを䜿甚する必芁があるこずを前述したした。これらのケヌスに2぀のクむック゜ヌト実装があるず、非垞にむラむラしたす。幞い、Rust特性を䜿甚しお、同じ゜ヌスコヌドから2぀のバヌゞョンのコヌドを自動的に生成できたす。このアプリは、この䟋で䜿甚したトリックを説明しおいたす。
最初に、Joiner関数から抜象化される型が定矩されたすjoin

 trait Joiner { ///    ,    . fn is_parallel() -> bool; ///   `rayon::join`,   `oper_a(); oper_b();`. fn join<A,R_A,B,R_B>(oper_a: A, oper_b: B) -> (R_A, R_B) where A: FnOnce() -> R_A + Send, B: FnOnce() -> R_B + Send; } 

このタむプには、シリアルおよびパラレル操䜜モヌドに察応する2぀の実装がありたす。

 struct Parallel; impl Joiner for Parallel { .. } struct Sequential; impl Joiner for Sequential { .. } 

これで、遞択した実装シヌケンシャルたたはパラレルを定矩するquick_sort型に関連するポリモヌフィック関数ずしお曞き換えるこずができたすJ: Joiner。小さな配列の䞊列バヌゞョンはシリアルになりたす

 fn quick_sort<J:Joiner, T:PartialOrd+Send>(v: &mut [T]) { if v.len() > 1 { //     ,    5K: if J::is_parallel() && v.len() <= 5*1024 { return quick_sort::<Sequential, T>(v); } let mid = partition(v); let (lo, hi) = v.split_at_mut(mid); J::join(|| quick_sort::<J,T>(lo), || quick_sort::<J,T>(hi)); } 

Source: https://habr.com/ru/post/J274299/


All Articles