C++11から、C++は言語仕様としてマルチスレッドプログラミングをサポートするようになりました。
C++を使うからには、スレッド間の通信も高速化したいものです。
というわけで、スレッド間通信の速度(レイテンシ)を測ってみます。
想定しているのは、仕事の完了を通知したいなどの状況で、以下の条件を満たす場合です。
- 通信のレイテンシを隠蔽する仕事が残っていない。
- 一つのスレッドが書き込み、他のスレッドは読み込むだけである(※)。
- スレッド数の管理は他で行っているため、スピンロックを行っても安全である(スピード狂)。
(※)このような場合でも、C++では同じ変数を同時に読み書きした場合は未定義動作であるので、正しくプログラムを書く必要があります。
環境
- Intel Core i7-4771 @ 3.5GHz
- bash on Windows
- g++-7 (Ubuntu 7.2.0-1ubuntu1~16.04) 7.2.0
- コンパイル方法:
g++-7 -pthread -O2
使用したコード
# include <iostream>
# include <vector>
# include <memory>
# include <thread>
# include <chrono>
# ifdef USE_CV
# include <mutex>
# include <condition_variable>
class cond_mutex {
std::mutex mtx;
bool ready;
std::condition_variable cv;
public:
cond_mutex() : ready(false) {}
void wait_for_read() {
std::unique_lock<std::mutex> lk(mtx);
cv.wait( lk, [this]{ return ready; } );
}
void wait_for_write() {
std::unique_lock<std::mutex> lk(mtx);
cv.wait( lk, [this]{ return !ready; } );
}
void notify_end_read() {
{
std::unique_lock<std::mutex> lk(mtx);
ready = false;
}
cv.notify_all();
}
void notify_end_write() {
{
std::unique_lock<std::mutex> lk(mtx);
ready = true;
}
cv.notify_all();
}
};
# else
# include <atomic>
class cond_mutex {
std::atomic<bool> ready;
public:
cond_mutex() : ready(false) {}
void wait_for_read() { while( !ready.load() ); }
void wait_for_write() { while( ready.load() ); }
void notify_end_read() { ready.store(false); }
void notify_end_write() { ready.store(true); }
};
# endif
void job( int id, int N, int loop, std::vector<cond_mutex>& vec ) {
auto& ipr = vec[id];
auto& opr = vec[(id+1)%N];
for( int i = 0; i < loop; ++i ) {
ipr.wait_for_read();
opr.wait_for_write();
opr.notify_end_write();
ipr.notify_end_read();
}
}
int main( int argc, char* argv[] ) {
const int N_threads = std::stoi( argv[1] );
const int N_loops = std::stoi( argv[2] );
std::vector<cond_mutex> vec(N_threads);
std::vector<std::unique_ptr<std::thread>> th(N_threads);
for( int i = 0; i < N_threads; ++i ) {
th[i] = std::make_unique<std::thread>( &job, i, N_threads, N_loops, std::ref(vec) );
}
const auto start = std::chrono::system_clock::now();
vec[0].notify_end_write();
for( int i = 0; i < N_threads; ++i ) {
th[i]->join();
}
const auto end = std::chrono::system_clock::now();
std::cout << std::chrono::duration<double, std::ratio<1, 1>>(end - start).count() << std::endl;
}
Nスレッドが輪番で仕事をやっていくという、プログラム(トークンリングのイメージ)です。
自明に遅いもの
条件変数(std::condition_variable
)
スピンロックが使えない場合はこれを使うべきですが、OSが介在するためとても遅くなります。
速いもの
アトミック変数(std::atomic
)
今回の用途では、アトミック変数を使うのがよさそうです。
アトミック変数を普通に使うと、不可分にアクセスするだけでなく、暗黙に逐次一貫性を満たしたアクセスが保証されます。
最適化によってメモリアクセスの順番が勝手に変わってしまったりしない、という意味です。
さらに進んで:弱いメモリバリア(std::memory_order_release
)
今回のケースでは、書き込み時にstd::memory_order_release
を用いても問題ありません。
弱いメモリバリアを使うときは、メモリ一貫性モデルについてよく理解したうえで細心の注意が必要です。
ただし、x86に限っては、ハードウェアレベルでそれなりに強いメモリ一貫性モデルを持っているため、プログラマができる最適化は、書き込み時にstd::memory_order_release
を指定する以外に意味のあることをほとんどできません(load
の方はどのような場合でもMOV
命令になり、store
の方はstd::memory_order_release
かそれより弱ければMOV
命令、std::memory_order_seq_cst
(デフォルト)であればXCHG
命令(orMOV
命令+メモリフェンス命令)になります)。
その命令より後方に存在するロード・ストア命令が先行して実行されても問題ない場合は、これを指定するとよいでしょう。
速度調査
論理コア数は8なので、8スレッドを上限に測定しました。
100回実行し、その平均から一回当たりのレイテンシを推測しました。プログラムの実行にかかる時間には、スレッドを生成するなどの他の仕事や、OSが割り込んできた時間なども計上されますが、その割合は微々たるものです。
条件変数を使ったコードの場合は10万ループ、アトミック変数を使ったコードの場合は500万ループで計測しました(実行時間が0.5秒~5秒程度となるように調整しました)。
実行時間
条件変数 | atomic | release | |
---|---|---|---|
2スレッド | 0.82±0.25(s) | 0.59±0.06(s) | 0.46±0.04(s) |
3スレッド | 1.05±0.14(s) | 0.99±0.02(s) | 0.79±0.01(s) |
4スレッド | 1.21±0.01(s) | 1.37±0.03(s) | 1.09±0.01(s) |
5スレッド | 1.58±0.01(s) | 1.80±0.03(s) | 1.41±0.01(s) |
6スレッド | 1.86±0.01(s) | 2.25±0.03(s) | 1.74±0.01(s) |
7スレッド | 2.25±0.14(s) | 2.69±0.03(s) | 2.06±0.02(s) |
8スレッド | 4.06±0.63(s) | 3.28±0.60(s) | 2.39±0.06(s) |
なんだか条件変数を使った方はスレッド数を変えたときのばらつきが大きいですね……。
8スレッドフル稼働の場合は、他のプロセスに邪魔されるので、まともな測定結果とはいえなくなっています。
一通信当たりのレイテンシ(平均)
条件変数 | atomic | release | |
---|---|---|---|
2スレッド | 4080ns | 59.3ns | 45.6ns |
3スレッド | 3490ns | 66.2ns | 52.6ns |
4スレッド | 3040ns | 68.7ns | 54.4ns |
5スレッド | 3150ns | 71.9ns | 56.3ns |
6スレッド | 3110ns | 75.0ns | 57.8ns |
7スレッド | 3220ns | 76.9ns | 58.8ns |
8スレッド | 5080ns | 81.9ns | 59.8ns |
先の結果から平均的なレイテンシを出すとこのようになります。
std::memory_order_release
を使うだけで、35%程レイテンシが改善することがわかります。
ばらつき具合についてはこれだけではわかりません。
レイテンシのばらつき・待ち時間の計測
アトミック変数を使ったバージョンを使います。50nsという短い時間をタイマーで計測するのは、タイマーを読む時間がかかるせいでうまくいきません。そこで、どのくらいの計算が実行可能かで調べます。
while(...);
となっていたところを、while(...)tmp += 1.0;
に置き換えてみます。
この部分は、addsd xmm0, xmm1
のようなアセンブリコードにコンパイルされます。
このwhile
ループを抜けてきたときにtmp
がいくつになっているかを確認することで、何cycleくらい立ち止まっていたかを計測します。
atomic | release | |
---|---|---|
2スレッド | 32.1± 3.5 | 92.1± 7.7 |
3スレッド | 65.6±24.6 | 151.7± 9.4 |
4スレッド | 99.2±36.7 | 201.9±22.4 |
5スレッド | 124.5±47.0 | 236.2±28.4 |
6スレッド | 174.7±53.9 | 286.1±34.4 |
7スレッド | 207.0±59.3 | 317.4±36.9 |
8スレッド | 240.5±44.2 | 347.6±33.2 |
std::atomicを普通に使った場合、待っている間に実行可能なaddsd
命令の数は、大体33個くらいだということがわかりました。
addsd
命令のレイテンシは5cycleで、このCPUは3.5GHzで動いているので、これにかかる時間は50nsくらいであり、おおむね合っていることがわかります。
std::memory_order_release
を使った場合、実行速度は速くなっているのに、addsd
命令をより多く実行できているという、一見不思議な現象が起こっています。これは、同期待ち(ストアが完了するのを待つこと)をしないため、ロード命令を先に発行できることによるものと考えられます。
余談
2スレッドの時、レイテンシが70cycleほどになる実行と、レイテンシが150cycleほどになる実行に分かれることがわかります。これは、おそらく両スレッドが同一コアで実行されていることによるものだと推測できます。
まとめ
- 条件変数を使うと、3~4マイクロ秒のレイテンシがある。
-
std::atomic
を普通に使うと、同一コア内なら20ns、別コアのスレッドなら40nsほどのレイテンシがある。 -
std::memory_order_release
を使えるときは使うと、そのレイテンシを35%ほど改善できる上、同期待ちに無駄な時間を費やさなくて済む。