C++で正確に時間を計測する基本的な考え方
C++で処理時間を計測する場合は、標準ライブラリの<chrono>を使用するのが基本です。
ただし、時間計測では「ナノ秒で表示できること」と「ナノ秒単位で正確に計測できること」は同じではありません。
信頼できる時間計測を行うためには、次の違いを理解する必要があります。
- 時間の表示単位
- クロックの分解能
- 時計の精度
- 時計の単調性
- 計測処理そのもののオーバーヘッド
- 測定結果のばらつき
一般的な処理の経過時間を測る場合は、std::chrono::steady_clockを使用するのが適切です。
基本的な計測コード
#include <chrono>
#include <cstdint>
#include <iostream>
void target_function()
{
volatile std::uint64_t sum = 0;
for (std::uint64_t i = 0; i < 1'000'000; ++i) {
sum += i;
}
}
int main()
{
using Clock = std::chrono::steady_clock;
const auto start = Clock::now();
target_function();
const auto end = Clock::now();
const auto elapsed =
std::chrono::duration_cast<std::chrono::microseconds>(
end - start
);
std::cout << "Elapsed: "
<< elapsed.count()
<< " microseconds\n";
}
時間計測の基本構造は、次のとおりです。
const auto start = Clock::now();
// 計測対象の処理
const auto end = Clock::now();
const auto elapsed = end - start;
end - startの結果は、時間間隔を表すstd::chrono::duration型になります。
なお、サンプルコードで32ビットのintを使って大量の加算を行うと、符号付き整数オーバーフローが発生する可能性があります。
C++における符号付き整数オーバーフローは未定義動作であるため、十分な範囲を持つstd::uint64_tなどを使用する必要があります。
C++で使用できるクロックの違い
C++では、主に次の3種類のクロックが使用されます。
std::chrono::system_clock
std::chrono::steady_clock
std::chrono::high_resolution_clock
用途に応じて適切なクロックを選ぶことが重要です。
std::chrono::steady_clock
処理にかかった経過時間を測る場合は、通常steady_clockを使用します。
using Clock = std::chrono::steady_clock;
steady_clockには、時刻が後戻りしないという特徴があります。
そのため、次のようなシステム時刻の変更による影響を受けにくく、経過時間の計測に適しています。
- ユーザーによるシステム時刻の変更
- NTPなどによる時刻補正
- OSによる実時間時計の調整
ただし、steady_clockは「最も高精度な時計」や「必ずナノ秒単位で正確な時計」を意味するものではありません。
保証される主な性質は、時計が後戻りせず、一定の速度で進むことです。
そのため、正確な表現としては、次のようになります。
steady_clockはシステム時刻の変更によって後戻りしないため、処理時間や経過時間の計測に適している。
std::chrono::system_clock
system_clockは、現在の日付や時刻を取得するときに使用します。
const auto now = std::chrono::system_clock::now();
主な用途は、次のような日時情報の取得です。
- ログに現在時刻を記録する
- ファイルの作成日時を記録する
- 現在の日付を表示する
- 時刻をカレンダー形式へ変換する
system_clockでも経過時間を計測できますが、システム時刻が変更された場合に時計が進んだり戻ったりする可能性があります。
そのため、用途は次のように分けるのが基本です。
// 現在日時を取得する
std::chrono::system_clock
// 処理にかかった時間を測る
std::chrono::steady_clock
なお、タイムゾーンや夏時間は、主に時刻を人間向けの日付・時刻へ変換するときに影響します。
system_clockの内部値そのものが、タイムゾーン変更によって必ず変化するわけではありません。
std::chrono::high_resolution_clock
high_resolution_clockは、名前だけを見ると最も高精度なクロックのように見えます。
using Clock = std::chrono::high_resolution_clock;
しかし、C++標準では、high_resolution_clockが独立したクロックであることは保証されていません。
実装によっては、次のいずれかになります。
system_clockの別名steady_clockの別名- 独立した別のクロック
また、単調増加するかどうかも実装によって異なります。
したがって、経過時間の計測では、意図を明確にするためにも次のように記述するのが無難です。
using Clock = std::chrono::steady_clock;
時間の単位を変換する方法
std::chronoでは、計測した時間を秒、ミリ秒、マイクロ秒、ナノ秒などへ変換できます。
ミリ秒で取得する
const auto elapsed =
std::chrono::duration_cast<std::chrono::milliseconds>(
end - start
);
std::cout << elapsed.count() << " ms\n";
マイクロ秒で取得する
const auto elapsed =
std::chrono::duration_cast<std::chrono::microseconds>(
end - start
);
std::cout << elapsed.count() << " us\n";
ナノ秒で取得する
const auto elapsed =
std::chrono::duration_cast<std::chrono::nanoseconds>(
end - start
);
std::cout << elapsed.count() << " ns\n";
duration_castは、時間間隔を別の単位へ変換するための機能です。
秒を小数で取得する
整数ではなく、小数を含む秒数として取得する場合は、doubleを内部表現に持つdurationを使用します。
const std::chrono::duration<double> elapsed = end - start;
std::cout << elapsed.count() << " seconds\n";
小数ミリ秒として表示する場合は、次のようにします。
const std::chrono::duration<double, std::milli> elapsed =
end - start;
std::cout << elapsed.count() << " ms\n";
小数マイクロ秒の場合は、次のとおりです。
const std::chrono::duration<double, std::micro> elapsed =
end - start;
std::cout << elapsed.count() << " us\n";
整数型への変換では端数が切り捨てられる
次のコードでは、結果が整数ミリ秒になります。
const auto elapsed =
std::chrono::duration_cast<std::chrono::milliseconds>(
end - start
);
実際の処理時間が0.8msだった場合、整数ミリ秒への変換結果は0msになる可能性があります。
より細かな値を確認したい場合は、マイクロ秒を使用するか、小数ミリ秒として取得します。
const std::chrono::duration<double, std::milli> elapsed =
end - start;
ただし、単位を細かくしても、元のクロックが持っていない精度が新たに追加されるわけではありません。
表示単位と計測精度の違い
次のコードは、計測結果をナノ秒単位へ変換します。
const auto nanoseconds =
std::chrono::duration_cast<std::chrono::nanoseconds>(
end - start
).count();
しかし、このコードで結果がナノ秒表示されたとしても、1ナノ秒単位で正確に計測できているとは限りません。
例えば、実際のクロックが100ナノ秒ごとに更新される場合、結果は次のような刻みになる可能性があります。
100 ns
200 ns
300 ns
400 ns
型や表示形式がナノ秒に対応していても、時計そのものが1ナノ秒ごとに更新されているとは限りません。
時間計測で区別すべき要素
時間計測では、次の要素を分けて考える必要があります。
| 項目 | 意味 |
|---|---|
| 単位 | 秒、ミリ秒、マイクロ秒、ナノ秒などの表現 |
| 分解能 | クロックが区別できる最小時間間隔 |
| 精度 | 実際の時間にどの程度近いか |
| 単調性 | 時計が後戻りしないか |
| オーバーヘッド | 時計を読み取る処理自体にかかる時間 |
| 再現性 | 同じ条件で似た結果が得られるか |
steady_clockの主な利点は単調性であり、必ずしも最も高い分解能や精度を持つとは限りません。
クロックの公称周期を確認する方法
クロック型のperiodを確認すると、型として定義されている1ティックの時間を調べられます。
#include <chrono>
#include <iostream>
int main()
{
using Clock = std::chrono::steady_clock;
std::cout << "period numerator: "
<< Clock::period::num << '\n';
std::cout << "period denominator: "
<< Clock::period::den << '\n';
const double tick_seconds =
static_cast<double>(Clock::period::num) /
static_cast<double>(Clock::period::den);
std::cout << "tick period: "
<< tick_seconds
<< " seconds\n";
}
例えば、次のように表示されたとします。
period numerator: 1
period denominator: 1000000000
この場合、型としての1ティックは次の時間です。
1 / 1,000,000,000秒
つまり、1ナノ秒単位の表現を持っていることになります。
ただし、これは実際の時計が毎ナノ秒更新されることを保証するものではありません。
実際に観測できる最小時間差を調べる方法
now()を繰り返し呼び出し、取得値が変化したときの最小差を確認する方法があります。
#include <chrono>
#include <iostream>
int main()
{
using Clock = std::chrono::steady_clock;
auto previous = Clock::now();
auto minimum = Clock::duration::max();
for (int i = 0; i < 1'000'000; ++i) {
const auto current = Clock::now();
if (current > previous) {
const auto difference = current - previous;
if (difference < minimum) {
minimum = difference;
}
}
previous = current;
}
const auto minimum_ns =
std::chrono::duration_cast<std::chrono::nanoseconds>(
minimum
);
std::cout << "Observed minimum difference: "
<< minimum_ns.count()
<< " ns\n";
}
ただし、このコードで得られる値は、時計の厳密な分解能ではありません。
測定結果には、次の要因が含まれます。
now()を呼び出すコスト- コンパイラ最適化
- CPUの命令実行状況
- OSの割り込み
- スレッドの切り替え
- キャッシュ状態
- CPUの省電力機能
- CPU周波数の変動
したがって、この結果は、その環境で観測できた最小時間差として扱う必要があります。
短い処理は繰り返して計測する
数十ナノ秒から数マイクロ秒程度の短い処理を1回だけ測ると、計測処理そのもののコストが無視できなくなります。
1回だけ測る場合の問題
次のような計測では、対象処理よりもClock::now()の呼び出しのほうが重い可能性があります。
const auto start = Clock::now();
const int result = a + b;
const auto end = Clock::now();
非常に短い処理では、対象処理を大量に繰り返して合計時間を測定します。
#include <chrono>
#include <cstdint>
#include <iostream>
int main()
{
using Clock = std::chrono::steady_clock;
constexpr std::int64_t iterations = 10'000'000;
volatile std::uint64_t result = 0;
const auto start = Clock::now();
for (std::int64_t i = 0; i < iterations; ++i) {
result += static_cast<std::uint64_t>(i);
}
const auto end = Clock::now();
const std::chrono::duration<double, std::nano> total =
end - start;
const double average_ns =
total.count() / static_cast<double>(iterations);
std::cout << "Total: "
<< total.count()
<< " ns\n";
std::cout << "Average: "
<< average_ns
<< " ns per iteration\n";
}
1回あたりの平均時間は、次の式で求めます。
合計時間 ÷ 実行回数
ただし、単純に繰り返せば必ず正確になるわけではありません。
コンパイラがループ全体を最適化したり、同じ計算をまとめたりする可能性があるためです。
コンパイラ最適化による処理削除に注意する
ベンチマークでは、処理結果が使用されていない場合、コンパイラが測定対象を削除することがあります。
戻り値を使用しない場合
int calculate()
{
int value = 0;
for (int i = 0; i < 1000; ++i) {
value += i;
}
return value;
}
int main()
{
const auto start = std::chrono::steady_clock::now();
calculate();
const auto end = std::chrono::steady_clock::now();
}
calculate()の戻り値が使われていないため、最適化ビルドでは関数呼び出し全体が削除される可能性があります。
最低限、結果を計測後に使用します。
const auto start = std::chrono::steady_clock::now();
const auto result = calculate();
const auto end = std::chrono::steady_clock::now();
std::cout << "Result: " << result << '\n';
ただし、結果を出力しただけで、計算が記述どおりに実行されることが保証されるわけではありません。
コンパイラは、観測可能な結果が変わらない範囲で、次のような最適化を行えます。
- 関数のインライン展開
- ループの削除や変形
- 定数畳み込み
- コンパイル時の計算
- 数式の置き換え
- 不要なメモリアクセスの削除
本格的なマイクロベンチマークでは、Google Benchmarkなどの専用ライブラリを使用するのが適切です。
volatileを使用する際の注意点
簡易的なベンチマークでは、最適化を抑える目的でvolatileが使われることがあります。
volatile std::uint64_t result = calculate();
しかし、volatileは汎用的なベンチマーク用の最適化防止機能ではありません。
volatileが保証しないこと
volatileは、次の機能を提供するものではありません。
- マルチスレッド同期
- CPUキャッシュの無効化
- メモリバリア
- 対象処理全体の最適化防止
- 記述した命令順序の完全な維持
- 正確なベンチマーク結果の保証
volatileは主に、メモリマップドI/Oなど、通常のプログラムとは異なる方法で値が変化する可能性があるオブジェクトを扱うための仕組みです。
また、C++20以降では、volatileなオブジェクトへの一部の複合代入やインクリメントに対して、コンパイラが警告を出す場合があります。
ベンチマーク専用ライブラリでは、次のような機能が用意されています。
benchmark::DoNotOptimize(result);
benchmark::ClobberMemory();
極端に短い処理を計測する場合は、こうした専用機能を使うほうが安全です。
Releaseビルドで計測する
実際の性能を測る場合は、原則として最適化されたビルドを使用します。
GCC・Clangの場合
g++ -std=c++20 -O2 benchmark.cpp -o benchmark
または、製品のビルド条件に応じて次のようにします。
g++ -std=c++20 -O3 benchmark.cpp -o benchmark
MSVCの場合
MSVCでは、一般的に次の最適化オプションを使用します。
/O2
Debugビルドでは、次のような理由により、実運用とは大きく異なる結果になる可能性があります。
- コンパイラ最適化が無効になっている
- 関数がインライン展開されない
- デバッグ用のチェックが追加される
- 不要な変数やスタック操作が残る
- 標準ライブラリのデバッグ機能が有効になる
ただし、-O3を使えば常に正しい測定になるわけではありません。
製品が-O2でビルドされるのであれば、ベンチマークも同じ条件で測る必要があります。
比較時に揃えるべきビルド条件
再現性のある比較を行うには、次の条件も揃えます。
- コンパイラの種類
- コンパイラのバージョン
- 最適化オプション
- LTOの有無
- CPU固有命令オプション
- 標準ライブラリの実装
- 例外機能やRTTIの設定
- アサーションの有無
ウォームアップを行う
初回実行だけ処理が遅くなる場合があります。
主な原因は次のとおりです。
- 命令キャッシュが温まっていない
- データキャッシュが温まっていない
- ページフォルトが発生する
- 動的ライブラリが遅延読み込みされる
- メモリアロケータが初期化される
- 分岐予測器が学習していない
- CPUが高い動作周波数へ移行していない
定常状態の性能を測る場合は、本計測前に対象処理を何度か実行します。
for (int i = 0; i < 100; ++i) {
target_function();
}
const auto start = std::chrono::steady_clock::now();
for (int i = 0; i < 1000; ++i) {
target_function();
}
const auto end = std::chrono::steady_clock::now();
ウォームアップ処理は、計測区間に含めません。
ウォームアップが適さない場合
初回起動時間やコールドスタート性能を測る場合は、ウォームアップを行うと実際の条件を再現できません。
測定目的は、次のように分ける必要があります。
- 定常状態の性能
- 初回実行の性能
- コールドキャッシュ時の性能
- 実運用に近い状態での性能
何を測りたいのかを明確にしたうえで、ウォームアップの有無を決めます。
1回ではなく複数回測定する
PC上の処理時間には、さまざまな要因によるばらつきがあります。
- OSの割り込み
- 別プロセスの実行
- スレッドの切り替え
- CPUのターボブースト
- CPU温度
- キャッシュミス
- メモリ帯域の競合
- セキュリティソフト
- 仮想環境
- バックグラウンド処理
そのため、1回だけの測定結果を代表値として扱うのは適切ではありません。
確認したい統計値
複数回測定した場合は、少なくとも次の値を確認します。
- 最小値
- 最大値
- 平均値
- 中央値
- 標準偏差
- P95
- P99
複数回測定するサンプル
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <vector>
volatile std::uint64_t sink = 0;
void target_function()
{
std::uint64_t value = 0;
for (std::uint64_t i = 0; i < 100'000; ++i) {
value += i;
}
sink = value;
}
int main()
{
using Clock = std::chrono::steady_clock;
using Nanoseconds =
std::chrono::duration<double, std::nano>;
constexpr std::size_t warmup_count = 100;
constexpr std::size_t measurement_count = 1000;
for (std::size_t i = 0; i < warmup_count; ++i) {
target_function();
}
std::vector<double> samples;
samples.reserve(measurement_count);
for (std::size_t i = 0; i < measurement_count; ++i) {
const auto start = Clock::now();
target_function();
const auto end = Clock::now();
const Nanoseconds elapsed = end - start;
samples.push_back(elapsed.count());
}
std::sort(samples.begin(), samples.end());
const double sum =
std::accumulate(
samples.begin(),
samples.end(),
0.0
);
const double mean =
sum / static_cast<double>(samples.size());
double median = 0.0;
if (samples.size() % 2 == 0) {
const std::size_t upper = samples.size() / 2;
const std::size_t lower = upper - 1;
median =
(samples[lower] + samples[upper]) / 2.0;
} else {
median = samples[samples.size() / 2];
}
double squared_difference_sum = 0.0;
for (const double value : samples) {
const double difference = value - mean;
squared_difference_sum += difference * difference;
}
const double standard_deviation =
std::sqrt(
squared_difference_sum /
static_cast<double>(samples.size())
);
std::cout << std::fixed << std::setprecision(2);
std::cout << "Minimum: "
<< samples.front() << " ns\n";
std::cout << "Median : "
<< median << " ns\n";
std::cout << "Mean : "
<< mean << " ns\n";
std::cout << "Maximum: "
<< samples.back() << " ns\n";
std::cout << "Stddev : "
<< standard_deviation << " ns\n";
}
この標準偏差は、取得したサンプル全体を母集団とみなした母標準偏差です。
母集団を推定するための標本標準偏差を求める場合は、サンプル数ではなくサンプル数 - 1で割ります。
平均値だけでなく中央値を見る
処理時間には、OSの割り込みなどによる大きな外れ値が混ざることがあります。
例えば、次の測定結果が得られたとします。
100 ns
101 ns
102 ns
103 ns
50000 ns
平均値は、50000nsという外れ値の影響を強く受けます。
一方、中央値は次の値です。
102 ns
通常の実行時間を把握したい場合は、中央値が有用です。
用途別に重視する指標
| 目的 | 重視する指標 |
|---|---|
| 一般的な実行時間 | 中央値 |
| 平均的な計算コスト | 平均値 |
| 理想的な最小コスト | 最小値 |
| ユーザーが感じる遅延 | P95・P99 |
| 最悪ケースの確認 | 最大値 |
最小値は理想的な条件を表しやすく、最大値は一時的なOS割り込みなどに大きく左右されます。
そのため、どれか1つの値だけで性能を判断しないことが重要です。
P95やP99の定義に注意する
パーセンタイルには複数の計算方法があります。
代表的な方式には、次のようなものがあります。
- nearest rank方式
- 線形補間方式
- inclusive方式
- exclusive方式
単純に次のようなコードで値を取得する場合は、簡易的な順位選択であることを理解する必要があります。
const double p95 =
samples[
static_cast<std::size_t>(
samples.size() * 0.95
)
];
厳密な比較を行う場合は、採用したパーセンタイルの定義を明記します。
nearest rank方式の例
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <stdexcept>
#include <vector>
double percentile_nearest_rank(
const std::vector<double>& sorted_samples,
double percentile
)
{
if (sorted_samples.empty()) {
throw std::invalid_argument(
"samples must not be empty"
);
}
percentile = std::clamp(percentile, 0.0, 1.0);
if (percentile == 0.0) {
return sorted_samples.front();
}
const auto rank = static_cast<std::size_t>(
std::ceil(
percentile * sorted_samples.size()
)
);
return sorted_samples[rank - 1];
}
この関数を使用する場合は、サンプルが事前に昇順で並べ替えられている必要があります。
計測処理そのもののオーバーヘッドを考慮する
Clock::now()を呼び出す処理にも時間がかかります。
次のようなコードで、2回のnow()呼び出しにかかる時間を簡易的に確認できます。
#include <chrono>
#include <iostream>
int main()
{
using Clock = std::chrono::steady_clock;
constexpr int iterations = 1'000'000;
const auto start = Clock::now();
for (int i = 0; i < iterations; ++i) {
const auto t1 = Clock::now();
const auto t2 = Clock::now();
static_cast<void>(t1);
static_cast<void>(t2);
}
const auto end = Clock::now();
const std::chrono::duration<double, std::nano> elapsed =
end - start;
std::cout
<< "Average cost of two now() calls: "
<< elapsed.count() / iterations
<< " ns\n";
}
ただし、このコードも次の影響を受けます。
- ループ処理のコスト
- コンパイラ最適化
- CPUパイプライン
- OS割り込み
- クロック実装
また、対象処理の測定値から空計測の時間を単純に差し引けば、正しい結果になるとは限りません。
短い処理では、対象処理を十分な回数まとめて実行し、計測処理の割合を小さくする方法が一般的です。
計測区間に不要な処理を含めない
計算処理だけを測りたい場合は、計測区間にコンソール出力を含めないようにします。
不適切な例
const auto start = Clock::now();
target_function();
std::cout << "finished\n";
const auto end = Clock::now();
このコードでは、対象処理だけでなくstd::coutの出力時間も計測されます。
適切な例
const auto start = Clock::now();
const auto result = target_function();
const auto end = Clock::now();
std::cout << result << '\n';
同様に、次の処理を計測対象に含めるかどうかも明確にします。
- ファイル読み込み
- ネットワーク通信
- メモリ確保
- ログ出力
- オブジェクト生成
- 入力データの作成
- 結果検証
- データ変換
アルゴリズムだけを測りたい場合は、入力データの準備を計測前に行います。
RAIIを使ったスコープタイマー
特定のスコープ全体の時間を測りたい場合は、RAII形式のタイマークラスを作ると便利です。
#include <chrono>
#include <iostream>
#include <string>
#include <utility>
class ScopeTimer
{
public:
explicit ScopeTimer(std::string name)
: name_(std::move(name)),
start_(Clock::now())
{
}
~ScopeTimer() noexcept
{
const auto end = Clock::now();
const std::chrono::duration<double, std::milli> elapsed =
end - start_;
try {
std::cout << name_
<< ": "
<< elapsed.count()
<< " ms\n";
} catch (...) {
// デストラクタから例外を外へ出さない
}
}
ScopeTimer(const ScopeTimer&) = delete;
ScopeTimer& operator=(const ScopeTimer&) = delete;
private:
using Clock = std::chrono::steady_clock;
std::string name_;
Clock::time_point start_;
};
使用例は次のとおりです。
void process()
{
ScopeTimer timer("process");
// 計測対象
}
スコープを抜けるとデストラクタが実行され、経過時間が出力されます。
string_viewを保持する場合の注意
タイマー名をstd::string_viewで保持すると、参照先の文字列がタイマーより先に破棄される可能性があります。
例えば、次のコードは危険です。
ScopeTimer timer(std::string("process"));
一時的なstd::stringがコンストラクタ終了後に破棄されるためです。
安全性を優先する場合は、std::stringとして文字列を所有する設計にします。
また、本番環境では、デストラクタから直接std::coutへ出力するよりも、例外を投げないロガーやコールバックへ結果を渡す設計が適しています。
関数を計測する汎用関数
関数を受け取って実行時間を表示する汎用関数も作成できます。
#include <chrono>
#include <functional>
#include <iostream>
#include <type_traits>
#include <utility>
template <typename Function, typename... Args>
decltype(auto) measure(
Function&& function,
Args&&... args
)
{
using Clock = std::chrono::steady_clock;
const auto start = Clock::now();
if constexpr (
std::is_void_v<
std::invoke_result_t<Function, Args...>
>
) {
std::invoke(
std::forward<Function>(function),
std::forward<Args>(args)...
);
const auto end = Clock::now();
const std::chrono::duration<double, std::milli> elapsed =
end - start;
std::cout << elapsed.count() << " ms\n";
} else {
decltype(auto) result =
std::invoke(
std::forward<Function>(function),
std::forward<Args>(args)...
);
const auto end = Clock::now();
const std::chrono::duration<double, std::milli> elapsed =
end - start;
std::cout << elapsed.count() << " ms\n";
return result;
}
}
このコードでは、std::is_void_vとstd::invoke_result_tを使用するため、<type_traits>のインクルードが必要です。
汎用関数の注意点
この実装には、次のような注意点があります。
- 計測対象が例外を投げると、時間が表示されない
- 出力処理の設計が固定されている
- 戻り値が参照の場合は、参照先の寿命に注意が必要
return (result);と括弧を付けると、戻り値型の推論が変わる可能性がある- ベンチマーク用途には機能が不足している
実務では、時間を返す機能と関数の戻り値を返す機能を分離したほうが、設計が分かりやすくなる場合があります。
Windowsで高分解能計測を行う方法
Windowsでは、QueryPerformanceCounterを使用して高分解能な時間計測を行えます。
#include <Windows.h>
#include <iostream>
void target_function()
{
// 計測対象
}
int main()
{
LARGE_INTEGER frequency;
LARGE_INTEGER start;
LARGE_INTEGER end;
if (!QueryPerformanceFrequency(&frequency)) {
std::cerr
<< "QueryPerformanceFrequency failed\n";
return 1;
}
if (!QueryPerformanceCounter(&start)) {
std::cerr
<< "QueryPerformanceCounter failed\n";
return 1;
}
target_function();
if (!QueryPerformanceCounter(&end)) {
std::cerr
<< "QueryPerformanceCounter failed\n";
return 1;
}
const double elapsed_seconds =
static_cast<double>(
end.QuadPart - start.QuadPart
) /
static_cast<double>(
frequency.QuadPart
);
std::cout << elapsed_seconds
<< " seconds\n";
}
QueryPerformanceFrequencyで取得する周波数は、システム起動時に決まり、プロセッサ間で一貫した値として扱われます。
ただし、一般的なC++アプリケーションでは、まずstd::chrono::steady_clockを使用するのが基本です。
標準ライブラリの実装が、内部で適切なOSの高分解能タイマーを使用していることが多く、コードの移植性も高いためです。
Linuxで高分解能計測を行う方法
LinuxやPOSIX環境では、clock_gettime()を使用できます。
#include <ctime>
#include <iostream>
double difference_seconds(
const timespec& start,
const timespec& end
)
{
const auto seconds =
static_cast<double>(
end.tv_sec - start.tv_sec
);
const auto nanoseconds =
static_cast<double>(
end.tv_nsec - start.tv_nsec
) /
1'000'000'000.0;
return seconds + nanoseconds;
}
void target_function()
{
// 計測対象
}
int main()
{
timespec start{};
timespec end{};
if (clock_gettime(CLOCK_MONOTONIC, &start) != 0) {
std::cerr << "clock_gettime failed\n";
return 1;
}
target_function();
if (clock_gettime(CLOCK_MONOTONIC, &end) != 0) {
std::cerr << "clock_gettime failed\n";
return 1;
}
std::cout << difference_seconds(start, end)
<< " seconds\n";
}
一般的な経過時間の計測では、CLOCK_MONOTONICを使用します。
Linuxで利用できる主なクロック
CLOCK_REALTIME
CLOCK_MONOTONIC
CLOCK_MONOTONIC_RAW
CLOCK_BOOTTIME
CLOCK_PROCESS_CPUTIME_ID
CLOCK_THREAD_CPUTIME_ID
主な用途は次のとおりです。
| クロック | 主な用途 |
|---|---|
CLOCK_REALTIME | 現在日時に対応する時刻 |
CLOCK_MONOTONIC | 一般的な経過時間 |
CLOCK_MONOTONIC_RAW | 時刻補正を受けない生のハードウェア時間 |
CLOCK_BOOTTIME | サスペンド時間を含む経過時間 |
CLOCK_PROCESS_CPUTIME_ID | プロセスのCPU時間 |
CLOCK_THREAD_CPUTIME_ID | スレッドのCPU時間 |
CLOCK_MONOTONIC_RAWが常にCLOCK_MONOTONICより優れているわけではありません。
一般的な処理時間の計測では、std::chrono::steady_clockまたはCLOCK_MONOTONICで十分です。
壁時計時間とCPU時間の違い
時間計測では、壁時計時間とCPU時間を区別する必要があります。
壁時計時間
壁時計時間は、処理開始から終了までに現実世界で経過した時間です。
C++では、一般的に次を使用します。
std::chrono::steady_clock
処理中にスリープした時間や、ロック待ち、I/O待ちがあれば、それらも含まれます。
CPU時間
CPU時間は、プロセスまたはスレッドが実際にCPU上で動作していた時間です。
Linuxでは、次のクロックを使用できます。
CLOCK_PROCESS_CPUTIME_ID
CLOCK_THREAD_CPUTIME_ID
処理がスリープしている間は、CPU時間はほとんど増えません。
用途別の選び方
| 目的 | 適した時間 |
|---|---|
| ユーザーが待った時間 | 壁時計時間 |
| APIの応答時間 | 壁時計時間 |
| ネットワーク通信を含む処理 | 壁時計時間 |
| CPU使用コスト | CPU時間 |
| スレッド単体の計算量 | スレッドCPU時間 |
| ロック待ちを含む処理 | 壁時計時間 |
マルチスレッドのプロセスでは、複数スレッドのCPU時間が合算されることがあります。
例えば、4つのスレッドが同時に1秒間CPUを使用した場合、次のようになる可能性があります。
壁時計時間:約1秒
プロセスCPU時間:約4秒
そのため、CPU時間と壁時計時間を単純に比較してはいけません。
sleep_forの精度に注意する
次のコードは、必ず正確に1ミリ秒だけ停止するわけではありません。
std::this_thread::sleep_for(
std::chrono::milliseconds(1)
);
実際の再開時刻は、次の要因によって遅れる場合があります。
- OSのスケジューラ
- システム負荷
- タイマーの分解能
- 割り込み
- CPUの省電力状態
- 仮想環境
指定した時間は待機時間の目安であり、厳密な再開時刻を保証するものではありません。
周期処理ではsleep_untilを使用する
途中の処理時間 + sleep_for()を繰り返すと、少しずつ遅れが蓄積します。
周期的な処理では、絶対時刻を基準に待機する方法が有効です。
#include <chrono>
#include <thread>
auto next = std::chrono::steady_clock::now();
while (running) {
next += std::chrono::milliseconds(10);
do_work();
std::this_thread::sleep_until(next);
}
ただし、sleep_until()を使用しても、通常のデスクトップOSでは厳密なリアルタイム性は保証されません。
マルチスレッド環境での計測
マルチスレッド処理を測る場合は、単純な経過時間だけでは性能低下の原因を判断できないことがあります。
処理時間には、次の要素が含まれる可能性があります。
- ロック待ち
- 条件変数の待機
- CPUコア間のキャッシュ同期
- スレッド生成
- コンテキストスイッチ
- NUMAノード間アクセス
- 他スレッドとの競合
複数スレッドを同時に開始したい場合は、開始前に同期を取ります。
C++20では、次の機能を使用できます。
std::barrier
std::latch
各スレッドが異なるタイミングで開始すると、比較条件が不公平になる可能性があります。
CPUコア固定を行う場合の注意点
高精度なマイクロベンチマークでは、スレッドを特定のCPUコアへ固定することがあります。
CPUコア固定により、次の変動を減らせる場合があります。
- CPUコア間の移動
- コアごとのキャッシュ差
- NUMA配置の変化
- 高性能コアと高効率コアの違い
- CPUごとの負荷差
一方で、CPU固定には次の欠点があります。
- 実運用の条件から離れる
- 固定したコアに別の負荷が集中する
- OS固有の実装が必要になる
- SMTの兄弟スレッドの影響を受ける
- ハイブリッドCPUではコアの種類に左右される
アルゴリズム単体の比較では有効ですが、実際のアプリケーション性能を確認する場合は、通常のスケジューリング環境でも測定する必要があります。
CPU周波数と温度による影響
現代のCPUは、負荷や温度に応じて動作周波数が変化します。
そのため、同じコードでも測定条件によって結果が変わる場合があります。
主な影響要因は次のとおりです。
- ターボブースト
- CPU温度
- 省電力設定
- バッテリー動作
- AC電源接続
- バックグラウンド処理
- 長時間負荷によるサーマルスロットリング
再現性を高めるには、次の条件を記録します。
- CPUモデル
- OSの種類とバージョン
- コンパイラとバージョン
- コンパイルオプション
- 電源プラン
- AC電源またはバッテリー
- 測定回数
- ウォームアップ回数
- 入力データ
- CPUコア固定の有無
比較順序による影響を避ける
アルゴリズムAとBを比較するときに、常にAから測定すると、順序による影響が生じる可能性があります。
例えば、Aの実行によってキャッシュが温まり、Bが有利になる場合があります。
測定順序を入れ替える
次のように順序を交互にします。
A → B
B → A
A → B
B → A
または、測定順序をランダム化します。
入力データも、比較対象ごとに同じ条件を使用します。
乱数を使用する場合は、同じシードを設定します。
std::mt19937 random_engine(12345);
入力条件が異なると、アルゴリズムではなく入力データの違いを測ることになります。
実用的なベンチマークテンプレート
一般的な関数の実行時間を複数回測る場合は、次のようなテンプレートを使用できます。
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <vector>
struct BenchmarkResult
{
double minimum_us;
double median_us;
double mean_us;
double maximum_us;
};
template <typename Function>
BenchmarkResult benchmark(
Function&& function,
std::size_t warmup_count,
std::size_t measurement_count
)
{
using Clock = std::chrono::steady_clock;
using Microseconds =
std::chrono::duration<double, std::micro>;
if (measurement_count == 0) {
throw std::invalid_argument(
"measurement_count must be greater than zero"
);
}
for (std::size_t i = 0; i < warmup_count; ++i) {
function();
}
std::vector<double> samples;
samples.reserve(measurement_count);
for (std::size_t i = 0; i < measurement_count; ++i) {
const auto start = Clock::now();
function();
const auto end = Clock::now();
const Microseconds elapsed = end - start;
samples.push_back(elapsed.count());
}
std::sort(samples.begin(), samples.end());
const double sum =
std::accumulate(
samples.begin(),
samples.end(),
0.0
);
const double mean =
sum / static_cast<double>(samples.size());
double median = 0.0;
if (samples.size() % 2 == 0) {
const std::size_t upper = samples.size() / 2;
const std::size_t lower = upper - 1;
median =
(samples[lower] + samples[upper]) / 2.0;
} else {
median = samples[samples.size() / 2];
}
return BenchmarkResult{
.minimum_us = samples.front(),
.median_us = median,
.mean_us = mean,
.maximum_us = samples.back()
};
}
使用例は次のとおりです。
#include <cstdint>
#include <iostream>
volatile std::uint64_t output_sink = 0;
void target_function()
{
std::uint64_t result = 0;
for (std::uint64_t i = 0; i < 100'000; ++i) {
result += i;
}
output_sink = result;
}
int main()
{
const BenchmarkResult result =
benchmark(
target_function,
100,
1000
);
std::cout << std::fixed << std::setprecision(3);
std::cout << "Minimum: "
<< result.minimum_us
<< " us\n";
std::cout << "Median : "
<< result.median_us
<< " us\n";
std::cout << "Mean : "
<< result.mean_us
<< " us\n";
std::cout << "Maximum: "
<< result.maximum_us
<< " us\n";
}
このコードは一般的な測定には利用できますが、極端に短い処理ではコンパイラ最適化の影響を十分に防げない場合があります。
Google Benchmarkを使用する
本格的なマイクロベンチマークでは、手書きの計測コードよりもGoogle Benchmarkなどの専用ライブラリが適しています。
#include <benchmark/benchmark.h>
static void BM_TargetFunction(
benchmark::State& state
)
{
for (auto _ : state) {
const auto result = target_function();
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(BM_TargetFunction);
BENCHMARK_MAIN();
Google Benchmarkの主な利点
- 適切な反復回数の調整
- ウォームアップ
- 統計情報の出力
- コンパイラ最適化の抑制
- 複数パラメータでの測定
- CPU時間と実時間の選択
- JSONなどへの結果出力
- 複数回実行と集計
Google Benchmarkでは、デフォルトでCPU時間が重視されるため、スリープやI/Oを含む処理で壁時計時間を測りたい場合は、実時間を使用する設定を行います。
BENCHMARK(BM_TargetFunction)->UseRealTime();
ネットワーク、ディスクI/O、ロック待ちなどを含む処理では、CPU時間と実時間のどちらを測っているのかを明確にする必要があります。
よくある時間計測の間違い
system_clockで経過時間を測る
using Clock = std::chrono::system_clock;
経過時間には、システム時刻の変更によって後戻りしないsteady_clockを使用します。
using Clock = std::chrono::steady_clock;
high_resolution_clockなら必ず高精度だと思う
using Clock = std::chrono::high_resolution_clock;
high_resolution_clockは、system_clockやsteady_clockの別名である可能性があります。
経過時間には、目的が明確なsteady_clockを使用するのが無難です。
ナノ秒表示ならナノ秒精度だと思う
std::chrono::duration_cast<std::chrono::nanoseconds>(
elapsed
);
これは表示や表現の単位をナノ秒へ変換しているだけです。
実際の時計が1ナノ秒単位で正確に動作していることを保証するものではありません。
1回だけ測る
1回だけの測定は、OSの割り込み、CPU周波数、キャッシュ状態などに大きく左右されます。
複数回測定し、中央値やP95なども確認します。
Debugビルドで測る
Debugビルドでは最適化が無効になり、実運用とは異なる結果になりやすくなります。
製品と同じコンパイル条件で測定します。
結果を使用しない
target_function();
結果が使用されていない場合、コンパイラによって処理が削除される可能性があります。
計測中にログを出力する
std::cout << value;
コンソール出力の時間が測定結果を支配する可能性があります。
出力は計測区間の外で行います。
ミリ秒へ変換して0になる
std::chrono::duration_cast<std::chrono::milliseconds>(
elapsed
);
1ミリ秒未満の値が整数変換によって切り捨てられている可能性があります。
マイクロ秒または小数ミリ秒を使用します。
用途別の推奨方法
一般的な関数の処理時間
std::chrono::steady_clock
を使用し、マイクロ秒または小数ミリ秒で表示します。
数十ナノ秒程度の短い処理
対象処理を多数回まとめて実行し、合計時間を実行回数で割ります。
Google Benchmarkなどの専用ライブラリを利用するのが適切です。
現在日時の取得
std::chrono::system_clock
を使用します。
Windows固有の高分解能計測
QueryPerformanceCounter
を使用できます。
Linux固有の高分解能計測
clock_gettime(CLOCK_MONOTONIC, ...)
を使用できます。
本格的な性能比較
次の情報を記録します。
- 中央値
- 平均値
- P95・P99
- 標準偏差
- サンプル数
- コンパイラ
- コンパイラバージョン
- コンパイルオプション
- CPU
- OS
- 入力条件
- 電源設定
- ウォームアップ条件
C++で時間を計測する際のまとめ
一般的な経過時間を測る基本コードは、次のとおりです。
using Clock = std::chrono::steady_clock;
const auto start = Clock::now();
// 計測対象
const auto end = Clock::now();
const std::chrono::duration<double, std::milli> elapsed =
end - start;
std::cout << elapsed.count() << " ms\n";
信頼できる計測を行うためには、次の点が重要です。
- 経過時間には
steady_clockを使用する - 現在日時には
system_clockを使用する high_resolution_clockを過信しない- ナノ秒表示とナノ秒精度を混同しない
- Releaseビルドで測る
- 製品と同じコンパイル条件を使用する
- 短い処理は多数回繰り返す
- コンパイラによる処理削除を防ぐ
- ウォームアップを行う
- 複数回測定する
- 平均値だけでなく中央値やP95も確認する
- 入出力やデータ準備を計測区間から分離する
- CPU時間と壁時計時間を区別する
- 本格的な比較には専用ベンチマークライブラリを使用する
C++の時間計測では、単純に開始時刻と終了時刻の差を求めるだけでは十分ではありません。
クロックの性質、コンパイラ最適化、OSのスケジューリング、CPUキャッシュ、動作周波数、計測オーバーヘッド、統計的なばらつきまで考慮することで、より信頼性の高い測定結果を得られます。
以上、C++での正確な時間計測の方法についてでした。
最後までお読みいただき、ありがとうございました。
