並列プログラミングの原則3選

株式会社アダコテックの柿崎です。製造業に向けた異常検知のAIソフトウェアを開発する会社でクラウドサービスなどの開発をしています。会社広報活動の一環としてnoteを使っていこうということで私も技術記事を書くことにしました。

最近アダコテックで開発しているパラメータの最適化モジュールで、インターンの学生さんが書いてくれたコードを全面的に修正しました。マルチコアプロセッサを使った並列処理がなされているのですが、コードレビューをしていくにつれて学びになることが多かったのでこれを題材にしようと思います。

この記事では並列プログラミングを始めたばかりの方がやりがちなコーディング、設計の課題と並列プログラミングを最適化するための原則を3つご紹介します。

アプリケーションに適したAPIを選択する

既存コードではタスクの並列化に.NET frameworkのParallel.ForEachが使われていました。おそらく特に根拠なく、自分の使えるものの中で手近なAPIを選択したと思われますが、これは適切だったのでしょうか。

まず、.NETのParallel.ForEachがどのような動きをするかを調べてみます。

APIリファレンスを見るだけでは詳細が判明しませんが、タスクのスケジュールがTaskSchedulerクラスを基にしていることがわかります。それではTaskSchedulerクラスのリファレンスを見てみましょう。

TaskSchedulerのリファレンスに次のような記述がみられます。

既定のタスク スケジューラは、負荷分散、スループット最大化のためのスレッドのインジェクション/リタイヤ、および全体のパフォーマンスの向上のためのワーク スティーリングを提供する .NET Framework 4 スレッド プールに基づいています。 ほとんどのシナリオでは、既定のタスク スケジューラで十分です。

とりあえず凄そうな雰囲気だけは感じます(苦笑)が、詳細はブラックボックスになっていてわかりません。ただ、スレッドプールが使われていてスレッドごとのキューにタスクが積まれて処理されていることがわかります。

一方で今回のアプリケーションがやりたいことを整理すると

・タスクをキューから取り出す
・pythonスクリプトを呼び出す
・スクリプトの完了を待って結果を集計する

というものでした。よく考察すれば、コア数分のpythonスクリプトを呼び出して、いずれかのスクリプトが完了するのを待ち受ければよいだけなので、本質的にはスレッドプールも、高度なタスク分配も不要だということがわかります。

私は今回のアプリケーションを、goroutineとsemaphore(チャネルを使って疑似的に)を使って実現しています。goroutineはParallel.ForEachとは異なり、今回のようなアプリケーションではいわゆるコルーチンのようにふるまい、複数スレッドが動作することはありません。ゆえにスレッド間の干渉やコンテキストスイッチといった無駄が少なく高速に動作することが期待できます。

アプリケーションに求められる並列処理の性質を理解して、適したモジュール、APIを選択することは並列プログラミングを行う上で極めて重要です。

※補足しておくと.NET frameworkにも今回のアプリケーションに適したAPIがあるのではないかと思います。私は.NETにそこまで精通しておらず、かつGo言語が使いたかったというのもあり、そちらでやってしまいましたが、.NET frameworkで組みたい方は探してみてください。

並列化したタスクの出力に標準出力、標準エラー出力は使わない

プログラム間の情報交換やデバッグのために標準出力や標準エラー出力を用いることはOSを問わず一般的に用いられる手法です。シングルスレッドのプログラミングで用いることは何の問題もありませんし、Linux等のUNIX系OSでは推奨される手法ですが、並列プログラミングにおいて、スレッドごとの出力に標準出力や標準エラー出力を用いることはお勧めしません。標準出力や標準エラー出力はプロセスで共有されるため、あるスレッドで出力を行う間、他のスレッドをブロックしてしまう可能性があり、並列化効率を著しく損なってしまうためです。

もし、スレッドの出力をログとして残したいのであれば、スレッドごとにファイルに保存するようにしましょう。またパイプのようにデータの交換をしたいのであれば、スレッド間通信を使うべきです。

ロックは極力作らず、通信により協調させる

既存コードのもう一つの問題点は、タスク間の結果のやり取りを共有変数とロックを使って行っている点です。具体的には次のようなコードです。

lock (bestUpdatingLockObj)
{
    // 最適解、最適値を更新
}
lock (progressLockObj)
{
    // 進捗を進める
}

ロックを使うと、その間はほかのスレッドがロックを獲得できないので安全に共有変数へアクセスできますが、その間ほかのスレッドは待機してしまうためこちらも並列化効率を下げる要因になります。また、設計を誤ってしまうとデッドロックという悩ましい現象が発生します。

上の例題のようなことを実現する場合はスレッド間通信(Goであればチャネル)を使って評価値を受け渡し、それを受け取った単一の集計スレッドが最適値や進捗を進めるという設計にすればロックを使う必要がなくなります。

結果

クリップボード一時ファイル01

例題として5832のタスクを64並列で解かせてみて、10回程度試行した結果をプロットしました。旧バージョンに比べて20%~40%程度の処理時間の改善を達成しました。さらに試行ごとの実行時間のブレも新バージョンでは非常に少なくなっている(1%未満)ことがわかるかと思います。

まとめとお勧め本

このように並列プログラミングの原則に従うことで実行結果に大きな差が生まれました。若手とベテランの差としては開発期間や品質、コミュニケーション力など様々な観点がありますが、並列プログラミングは比較的パフォーマンスに関して大きな差が生まれやすい分野だと思います。皆さんもぜひ腕を磨いてみてください。

最後に並列プログラミングをもっとうまく書きたい!という情熱あふれるエンジニアの方におすすめ本を紹介します。

一つ目は結城さんが書かれたJava言語で学ぶデザインパターン入門 マルチスレッド編です。こちらは並列プログラミングはじめましたの方に読んでいただきたい入門書です。並列(マルチスレッド)プログラミングをするにはどう書けばよいのかをデザインパターンという形でまとめられているのでひとつの指針になるかと思います。

上級者向けの書籍としてはThe Art of Multiprocessor Programmingが面白いです。初版が2009年と古く、すでに絶版しているようです(英語版はあります)。古いのでGoやRustなど最新の言語に対応できていない感はありますが、ロックやキューなどといった並列プログラミングに不可欠なオブジェクトの紹介やロックフリーキューや楽観的ロックといった高度な並列処理を実現するうえで必要なOS、ミドルウェアレイヤーの構造が詳細に書かれていることが特徴です。


アダコテックでは現在、エンジニアを絶賛募集中です!
プログラミングスキルを磨きたい、発揮したい方のご応募をお待ちしております。私が開発しているサービスの話や、転職した理由を載せておきますので興味がわいた方はそちらもあわせてご覧ください。


この記事が気に入ったらサポートをしてみませんか?