基礎勉強会 #3
並列・並行処理の基礎
今回の話
- 並列・並行処理の基礎知識
- クラシックな並行処理
- モダンな並行処理
- その他の並行処理
- Tips
- 参考資料
今回は話さない
- 低レベルの並列性: SIMD, スーパースカラ
- 分散処理
並列・並行処理の基礎知識
並列・並行処理の基礎知識
- なぜ並行処理が必要になるか
- 並列・並行・分散の違い
- 並行処理に関係するOSの知識
- スレッドとプロセス
- スケジューリング
- その他並列・並行に関連する用語
- CPU バウンドとI/O バウンド
- 同期と非同期
- 並列・並行処理に関する法則
なぜ並列・並行処理をするか?
すべては速くするため
なぜ並列・並行処理が必要になるか
CPU 環境の変化
- 昔
- 速い CPU を買えば、コードを変更しなくてもそれだけ速くなった (スケールアップ)
- 今
- CPU のクロック自体は対して変わらない
- コア数が増えるようになった
- コードがそのままだと速くならない
なぜ並列・並行処理が必要になるか
C10K 問題 or C10M 問題
- クライアント1万台問題1万台問題
- 1 つのサービスに数百万のクライアントが接続する
- ということは実際に起こっている
- サーバ単体で処理する訳ではないが、うまく並列・並行処理をする必要がある
「速さ」の種類
- スループット
- 一定の時間内にどれだけ処理できたか
- 「ファイルを100Mbpsでダウンロードできた」
- これはスループット
- レイテンシ
- 待ち時間の長さ
- 「クリックしてからレスポンスが帰ってくるまで」
- これはレイテンシ
並列・並行・(ついでに)分散の違い
- 並列
- 同時に複数の CPU を使う
- 異なるタスクを実行しなくても良い
- 並行
- 異なるタスクを"同時"に実行する
- 複数の CPU を使わなくてもよい
- 分散
- 並行・並列処理が複数のマシンにまたがる場合
並列
CPU1
CPU2
並行(並列でない)
CPU1
並行(かつ並列)
CPU1
CPU2
スレッドとプロセス
- プロセス
- プログラムの実行に必要なリソースをまとめたもの
- 次を持つ
- アドレス空間
- 各種状態 (ファイルディスクリプタ, レジスタ etc)
- 各種情報 (プロセス ID, ユーザ ID) ... など
- スレッド
- OSが CPU に割り当てる命令列
- 次を持つ
- レジスタ
- スタック ... など
- スレッド同士はメモリ空間を共有する
スケジューリング
- スケジューラ
- スケジューリングアルゴリズムによってスレッドの切り替えを行う
- スレッドの切り替え=コンテキストスイッチ
- コンテキストスイッチ
- 数が多いと無視できないコストになる
- 切り替え元スレッドのリソースを退避と再配置
- メモリアクセスが発生する
- 数が多いと無視できないコストになる
CPU バウンドと I/O バウンド
処理の種類によって並列化での性能向上が見込めるかは違う
- CPU バウンドな処理
- CPU 上の計算量などが性能に大きく関わる処理
- 😥 並列でない非同期処理
- 😊 並列度
- CPU 上の計算量などが性能に大きく関わる処理
- I/O バウンドな処理
- ネットワークやディスク読み書きが性能に大きく関わる処理
- 😊 並列でない非同期処理
- 😥 並列度
- ネットワークやディスク読み書きが性能に大きく関わる処理
同期と非同期
他の処理の完了を待って次の処理に移るかどうかの違い
- 同期 (ブロッキング)
- 処理の完了 or 一定量の処理の進行を待つ
- 非同期 (ノンブロッキング)
- 待たない。その時点の進捗分だけをすぐに返す
同期
非同期
イベント
イベントに対応する処理
Blocking
並列・並行処理に関する法則
- Amdahl の法則
- Gustofson の法則
Amdahl の法則
並列化によるスピードアップの法則
- データ量は固定、処理時間の変動をみる
- P - 並列化された部分の度合い
- S - 並列度 (= コア数)
Amdahl の法則
Gustafson の法則
並列化によるスピードアップの法則
- 処理時間固定で、処理できる量の変動をみる
- P - 並列化された部分の度合い
- S - 並列度 (= コア数)
Gustafson の法則
Amdahl の法則と Gustafson の法則
どう解釈するか?
- Amdahl の法則
- 一定量のデータに対する処理時間
- Gustafson の法則
- 一定時間内の処理量
Gustafson の方が見栄えはいいが、常には使えない
- 対象が大規模なデータでないとあまり意味がない
ここまでのまとめ
ここまではだいたい用語の話
- スループットとレイテンシ
- 並行と並列
- スレッドとプロセス
- スケジューリングとコンテキストスイッチ
- CPU バウンドと I/O バウンド
- 同期と非同期
- Amdahl と Gustafson
並列処理
並列処理
- 並列処理
- という場合は並行でない場合がほとんど
- どのような場合に行うか?
- 科学技術計算は並列に当てはまる場合が多い
- 行列計算
- ディープラーニング
- 画像処理
- できる場合にはやったほうがいい
- 並行処理に比べると楽に高速ができる
- GPGPU での高速化がやりやすい
- 科学技術計算は並列に当てはまる場合が多い
並列化の種類
- タスク並列
- データ並列
タスク並列
CPU1
CPU2
タスク
A
タスク
B
タスク
C
タスク
A
タスク
B
タスク
D
タスク
C
タスク
D
複数のタスクに分解して
CPU に振り分ける
データ並列
CPU1
CPU2
データ
A
処理対象のデータが分割できる場合
データ
B
データ
A
データ
B
並列処理のフレームワーク
- OpenMP
- OpenCV
- Intel Thrading Building Block
- MapReduce: Hadoop, Spark
- TensorFlow
OpenMPでの並列化
#pragma omp parallel for private(j,k)
for (i = 0; i < n; i++){
for (j = 0; j < n; j++){
C[i][j] = 0.0;
for (k = 0; k < n; k++){
C[i][j] += A[i][k] * B[k][j];
}
}
}
並行処理
並行処理は難しい
逐次処理で正確にコードを書くのは難しい
並列処理の場合はさらに難しくなる
- 逐次処理との違い
- 競合
- スレッドの扱い方
逐次処理との違い
- 逐次処理で問題のなかったコードが並列にすると、結果が予測できなくなる場合がある
- どういうものが問題になるか?
- DATA RACE
- RACE CONDITION
- REORDERING
- VISIBILITY
DATA RACE
スレッド1
スレッド2
f()
f()
int f() {
(*a)++;
};
DATA RACE
スレッド1
スレッド2
load a
store a+1
load a
store a+1
- アトミックな操作でない場合、CPU 上では操作が分割されている可能性がある
- (説明のための例です。念の為)
DATA RACE
スレッド1
スレッド2
01 load a
02 store a+1
03
04
01
02
03 load a
04 store a+1
case 1
01 load a
02
03
04 store a+1
01
02 load a
03 store a+1
04
case 2
- 実際にどのような結果になるかはタイミングの問題
- case 1 では a = a + 2
- case 2 では a = a + 1
- もっとおかしい結果になることもありうる
- ハードウェアレベルの問題かも
RACE CONDITION
atomic_int tmp = ATOMIC_VAR_INIT (0);
void bad_swap(atomic_int x, atomic_int y) {
atomic_store(&tmp, atomic_load(&x));
atomic_store(&x, atomic_load(&y));
atomic_store(&y, atomic_load(&tmp));
}
- メモリ操作をアトミックにして DATA RACE はなくしたが、このコードはRACE CONDITION = 競合状態を引き起こす
RACE CONDITION
swap(*x=3, *y=4)
swap(*u=5, *v=6)
01 tmp = x = 3
02 x = y = 4
03
04
05
06 y = tmp
01
02
03 tmp = u = 5
04 u = v = 6
05 v = tmp
06
*x=4, *y=6
*u=6, *v=5
- ba_swap 関数全体は並列に動かすと予期した結果にならない箇所 = クリティカルセクションを持つ
- クリティカルセクションは別途に保護が必要
DATA RACE と RACE CONDITION の違い
- DATA RACE
- 同時に同じメモリ領域に書き込みなどのアクセスすることで予期しない挙動が発生すること
- RACE CONDITION
- 設計の問題
- 本来実行順序の保証が必要な部分が、複数スレッドから実行されることで予期しない結果になること
REORDERING
int x[100000], y[100000];
x[4] = y[4];
x[99999] = y[99999];
x[5] = y [5];
x[99998] = y[99998];
- メモリへの load と store だけするコード
REORDERING
x[4] = y[4];
x[99999] = y[99999];
x[5] = y[5];
x[99998] = y[99998];
x[4] = y[4];
x[5] = y[5];
x[99999] = y[99999];
x[99998] = y[99998];
- 逐次処理の場合はどちらのコードも同じ結果
- しかし右側のコードの方が速い可能性がある
- 最適化により右のように実行されるかも
- コンパイラ or CPU のどちらか
VISIBILITY
int * ready;
void thread1() {
*ready = 0;
for (;;) {
if (*ready == 1) {
printf("start!\n");
break;
}
}
}
void thread2() {
sleep(10);
*ready = 1;
}
thread1 と thread2 を同時に走らせる
thread1 は停止するか?→ 停止しない
- 他のスレッドで行われた結果が見えないことがある
- 最適化により ready は一度しか読まれない
メモリモデル
load と store 命令にどのような並び替えが予想されるか
- really weak - C/C++ 言語
- どのような並び替えも発生しうるモデル
- weak with data dependency ordering - ARMv7
- データ依存(load間)の順序が保たれるモデル
- usually strong - Intel 64
- どのストアも並び替えが起こらない
- sequential consistent - 最適化なしシングルコア
- 並び替えは発生しない
See https://preshing.com/20120930/weak-vs-strong-memory-models/
対処方法
- Data Race
- アトミック性の追加
- or Race Condition と同じ対処
- Reordering, Visibility
- コンパイラの最適化の制御
- メモリバリア(or メモリフェンス)
- volatile の追加
- コンパイラの最適化の制御
RACE CONDITION の対処
- ロックでクリティカルセクションを保護する
- Mutex Lock
- Reader Writer Lock
- Semaphore
- Condition Lock
Mutex Lock
pthread_mutex_t swap_lock;
void swap(int * x, int * y) {
int tmp;
pthread_mutex_lock(&swap_lock);
tmp = *x;
*x = *y;
*y = *tmp;
pthread_mutex_unlock(&swap_lock);
}
相互排他をおこなう最も単純なロック
- 既にロックが取得されていた場合には解放されるまで待つ
- ロックが解放された場合に解放待ちのスレッドがあれば、そのうち1つだけがロックを取得できる
Reader Writer Lock
相互排他では効率的でない場合がある
- データの読み込み同士では問題は発生しづらい
- 書き込み時に問題が発生することが多い
- だいたいのプログラムでは
読み込み回数 >> 書き込み回数
このような場合には Reader Writer Lock を使う
Reader Writer Lock
var mu sync.RWLock
func read() {
mu.RLock()
defer mu.RUnlock()
println("read")
}
func write() {
mu.Lock()
defer mu.Unlock()
println("write")
}
- read() 同士は同時に実行される場合がある
- write() が実行される場合は他のread()もwrite() も実行されない
ロックに関する問題
- デッドロック
- ライブロック
- 再帰ロック
- 呼び起こし忘れ
- 公平性
デッドロック
複数のスレッドがお互いの処理を待ち合っている状態
func thread1() {
m1.Lock()
defer m1.Unlock()
time.Sleep(1 * time.Second)
m2.Lock()
defer m2.Unlock()
}
func thread2() {
m2.Lock()
defer m2.Unlock()
time.Sleep(1 * time.Second)
m1.Lock()
defer m1.Unlock()
}
スレッドを扱う
- pthread ライブラリ
- java.lang.Thread
pthread の場合
#include <pthread.h>
void thread_func(void * arg) {
int v = *(int*)arg;
printf("v is %d\n", v);
}
void some_func() {
pthread_t pthread;
int arg = 1;
pthread_create(&pthread, NULL, &thread_func, (void*)&arg);
pthread_join(pthread, NULL);
}
java.lang.Thread の場合
class MyTask implements Runnable {
int _v;
MyTask(int v) {
_v = v;
}
public void run() {
System.stdout.println("value is " + v);
}
}
MyTask t = new MyTask(42);
new Thread(t).start();
t.join();
スレッドに関するデザインパターン
- スレッドプール
- プロデューサー・コンシューマーパターン
- Future パターン
モダンな並行処理
居眠り床屋問題
- 画像のキャッシュを作る
- 画像 URL と画像データのマップを作りたい
これを並行処理する
- ロックを使えばすべて解決か?
実際にやるのは結構難しい
- エラーハンドリング
- ネットワークエラーが起きたらどうするか?
- キャンセル処理が必要になったらどうするか?
- タイムアウト
- 親のキャンセル
- 複製されたリクエスト
並行処理にはキャンセルやエラーハンドリングがつきもの
ロックはこういった機能を提供してくれるわけではない
クラシックな並行処理の問題
- ロックの問題
- ロックを正しく使うのは難しい
- ロックが足りない OR ロックをかけすぎ
- ロックをかける場所を間違える
- ロックの範囲が大きすぎる
- Composability の欠如
- ロックを正しく使うのは難しい
- スレッドの扱い
- たくさんスレッドを使いたいが
- スレッドはコストが高い
- コンテキストスイッチのコストも高い
- たくさんスレッドを使いたいが
- 他いろいろハマるポイントがある
モダンな並行処理
みんなロックを使いたくない
- ロックを使わずに並行処理をしたい
- 最近の言語では言語レベルで並行処理に対応している場合が多い
- C言語ですら: atomic, thread local storage (C11)
モダンな並行処理の例
- 軽量スレッド
- メッセージパッシング
- ソフトウェアトランザクショナルメモリ
- リアクティブプログラミング
- リアクティブシステム
- 関数型言語での並列・並行処理
軽量スレッド
- 言語やフレームワークが提供する「軽い」スレッド
- 「軽い」
- メモリフットプリントの軽さ
- コンテキストスイッチの軽さ
- スケジューリングも言語・フレームワーク側で行う
- 「軽い」
- 湯水のようにスレッドを大量に使うことができる
なお
- OSが提供するスレッドはネイティブスレッド
- 言語やフレームワークが提供するスレッドはグリーンスレッド
- グリーンスレッド = 軽量スレッドではない
- 軽いかどうかは実装次第
軽量スレッドがある言語の例
- Erlang, Elixer
- Go
- Kotlin
- 名前はコルーチンだがやっていることはほぼ軽量スレッド
メッセージパッシング
スレッド間でメッセージのやりとりを行う
- メモリアクセスに必要な同期の回避
スレッド1
スレッド2
Go での例
func thread(ch chan bool) {
for {
fmt.Print("waiting")
<-ch
fmt.Print("start!")
}
}
func main() {
ch := make(chan bool)
go thread(ch)
ch <- true
ch <- true
}
メッセージパッシングがある言語の例
- Erlang, Elixer
- Go
- Scala
- アクターモデルの一部
その他の並行処理
(おまけ)
その他の並行処理
- スクリプト言語における並行処理
- Android, iOS上の並行処理
- GPU
スクリプト言語における並行処理
- Python, Ruby, node.js など
- GIL (Global Interpreter Lock) があるため実のところシングルスレッドでうごく
- Ruby では GVL
- 非同期処理で並行処理はできるが並列にはなり辛い
- Ruby 3 や PyPy で GIL を外そうとする動きもあるので将来的には違うかも
GIL - Global Interpreter Lock
- スクリプト言語では、インタプリタがバイトコードを解釈してプログラムを動かしている
- じゃあ非同期にしても早くならないのでは?
- GIL が解放されるタイミングがある
- I/O 待ち
- ネットワークの応答待ち etc
- CPU だけを酷使するプログラムでは早くならないが、ネットワークなどのレイテンシが発生しやすいプログラムでは大いに意味がある
Android, iOS 上の並行処理
- Android, iOS 上のアプリは並列で動いている
- UI 用にスレッドが確保されている
- 初心者がはまりがちな部分
-
スレッドの使い方など制限がある
- UI スレッドに負荷をかけてはいけない
- ドキュメントをちゃんと読もう
- 非同期処理のやり方とか書いてあるはず
- ライブラリを使う
- RxJava, RxSwift
- コルーチンを使う
- Kotlin
-
スレッドの使い方など制限がある
GPU の並列・並行処理
- GPGPU
- GPU では
- 行列計算など
- 基本的に分岐(if) などはしない
- フレームワーク
- CUDA
- OpenCL
Tips
Tips
- 並列・並行処理の設計
- 並列・並行処理のテスト
- 並列・並行処理のデバッグ
- 並列・並行処理の検証
並行処理の設計
大まかに 2 パターン
- 逐次処理を書いて後から並列・並行化する
- 最初から並行処理として設計する
性能を出すためには 1 を進めたいが現実は 2 が多い
負荷の種類に対して適切な設計をする必要がある
- 並列処理で良い場合もある
- CPU バウンドなら非同期処理では速くならない
- I/O バウンドなら非同期処理で十分かもしれない
Intel のスレッド化手法
- 分析: 並行性を持つ箇所を見つける
- 設計と実装: アルゴリズムをスレッド化する
- 正当性の検証: 並行化によるエラーがないか確認
- 性能チューニング: 実行時間の短縮
並列・並行処理のテスト
- 当然難しい
- メッセージパッシングならある程度楽
- メッセージパッシングのエミュレーション
- Humble Object Pattern
- 非同期な部分と同期的な部分を分離するパターン
- テスト対象は同期的な部分に絞る
- ただし非同期性に対するテストではない
- メッセージパッシングならある程度楽
- 耐久試験、負荷試験なども時には必要になる
並列・並行処理のデバッグ
並行処理特有のバグはタイミングの問題で起こる
- 基本的にデバッガは使えないと考える
- Print デバッグでもうまくいかない場合がある
- ツールを使った解析でどうにかする
- C,C++ なら
- Halgrind
- Go なら
- --race オプション
- 言語ごとに便利なツールがある
- C,C++ なら
並列・並行処理の検証
デッドロックetcが起きるかどうか静的に解析できる
- Coffman 条件を満たすかどうか
- Formal Method 形式手法
- 仕様記述言語: CPS
- モデルチェッカ: Spin など
次回の候補
-
アルゴリズム
-
データ構造
-
ネットワーク
- 仮想化・コンテナ
- RDBMS, NoSQL
- オブジェクト指向プログラミング
- 関数型プログラミング
- 継続的インテグレーション
- 言語処理系
- 量子コンピューティング
参考資料
書籍
- 並行プログラミング技法
- Java Concurrency in Practice
- Java 並行処理プログラミング (絶版)
- 並行コンピューティング技法
- Art of Concurrency
- 低レベルプログラミング
- Go 言語による並行処理
- Haskell による並列・並行プログラミング
並列・並行処理の基礎
By Shingo Suzuki
並列・並行処理の基礎
- 2,139