はじめに
拙作のZ80エミュレータでは、メモリやI/Oアクセスのコールバックを元々関数ポインタで実装したのですが、 std::function
に変更してみたところ 実用に耐えられないレベルの性能劣化 が起きてしまったので、デフォルトは従来通り関数ポインタを使用する方式に戻しつつ、オプショナルで std::function
に置き換えることもできる対応をしているところです。
という訳で、具体的にどの程度の性能差が出るのか検証した結果を本書に記します。
関数ポインタではなく std::function
の方が(ラムダ式で変数キャプチャができて)便利なので、可能な限りそっちを使った方が良いのですが、ユースケースによっては関数ポインタを使わざるを得ないケースもあるということで、ご参考までに。
わざわざ C++ を使っている時点で性能がシビアなシーンでのユースケースが多いから、割と「std::functionだと遅すぎてアカン...」ということになりがちかと思いますが、関数ポインタと比較した性能評価の情報が見当たらなかったので書いてみました。
性能調査結果
調査環境
- OS: macOS version 12.6
- CPU: Intel Core i7 1.2GHz 4 cores
- Memory: 16GB 3733MHz LPDDR4X
- C++ Compiler: Apple clang version 14.0.0 (clang-1400.0.29.102)
調査プログラム
#include <chrono>
#include <functional>
#include <iostream>
int foo(int a, int b) {
return a * b + b;
}
int (*fp)(int, int); // 最適化によるインライン展開を避けるためグローバル変数で宣言
int main() {
fp = foo;
std::function<int(int, int)> fcBind = std::bind(foo, std::placeholders::_1, std::placeholders::_2);
std::function<int(int, int)> fcDirect = foo;
std::function<int(int, int)> fcLambda = [](int a, int b) { return a * b + b; };
int n = 1;
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 100000; j++) {
n = fp(n, i + j);
}
}
auto end = std::chrono::steady_clock::now();
std::cout << "Elapsed time of Function Pointer ... "
<< std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
<< " μs" << std::endl;
int n1 = 1;
start = std::chrono::steady_clock::now();
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 100000; j++) {
n1 = fcBind(n1, i + j);
}
}
end = std::chrono::steady_clock::now();
std::cout << "Elapsed time of std::function (bind) ... "
<< std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
<< " μs" << std::endl;
int n2 = 1;
start = std::chrono::steady_clock::now();
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 100000; j++) {
n2 = fcDirect(n2, i + j);
}
}
end = std::chrono::steady_clock::now();
std::cout << "Elapsed time of std::function (direct) ... "
<< std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
<< " μs" << std::endl;
int n3 = 1;
start = std::chrono::steady_clock::now();
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 100000; j++) {
n3 = fcLambda(n3, i + j);
}
}
end = std::chrono::steady_clock::now();
std::cout << "Elapsed time of std::function (lambda) ... "
<< std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()
<< " μs" << std::endl;
return n == n1 && n1 == n2 && n2 == n3 ? 0 : -1;
}
コンパイルオプション
以下4パターンで検証しました
# 最適化なし
clang++ -std=c++11 test.cpp -lstdc++
# 最適化: -O
clang++ -O -std=c++11 test.cpp -lstdc++
# 最適化: -O2
clang++ -O2 -std=c++11 test.cpp -lstdc++
# 最適化: -Ofast ※非推奨
clang++ -Ofast -std=c++11 test.cpp -lstdc++
実行結果
(最適化なし)
Elapsed time of Function Pointer ... 195589 μs
Elapsed time of std::function (bind) ... 5574856 μs
Elapsed time of std::function (direct) ... 2570016 μs
Elapsed time of std::function (lambda) ... 2417802 μs
- bind: 関数ポインタ の 約28倍 遅い
- direct: 関数ポインタ の 約13倍 遅い
- lambda: 関数ポインタ の 約12倍 遅い
(最適化: -O
)
Elapsed time of Function Pointer ... 184692 μs
Elapsed time of std::function (bind) ... 2293151 μs
Elapsed time of std::function (direct) ... 827113 μs
Elapsed time of std::function (lambda) ... 580442 μs
- bind: 関数ポインタ の 約12倍 遅い
- direct: 関数ポインタ の 約4倍 遅い
- lambda: 関数ポインタ の 約3倍 遅い
(最適化: -O2
)
Elapsed time of Function Pointer ... 154206 μs
Elapsed time of std::function (bind) ... 197683 μs
Elapsed time of std::function (direct) ... 209626 μs
Elapsed time of std::function (lambda) ... 167703 μs
- bind: 関数ポインタ の 約1.28倍 遅い
- direct: 関数ポインタ の 約1.36倍 遅い(bindよりも速いこともあり平均的には同じぐらい)
- lambda: 関数ポインタ の 約1.09倍 遅い(ほぼ関数ポインタと同等)
(最適化: -Ofast
) ※非推奨
Elapsed time of Function Pointer ... 154332 μs
Elapsed time of std::function (bind) ... 250490 μs
Elapsed time of std::function (direct) ... 255401 μs
Elapsed time of std::function (lambda) ... 164773 μs
- bind: 関数ポインタ の 約1.62倍 遅い
- direct: 関数ポインタ の 約1.65倍 遅い
- lambda: 関数ポインタ の 約1.07倍 遅い
評価結果
-
-Ofast
については NaNチェックが無効化される ため実用的な用途では非推奨 -
-O2
で最適化すれば概ね-Ofast
と同じぐらいの性能が出る -
std::function
を使いたいが性能も確保したいシーンでは、極力ラムダ式を使うのが良さそう
-O2
を指定しておけば、関数ポインタの性能比が概ね許容範囲かもしれないので、-O2
が指定できるケースであれば関数ポインタを std::function
に置き換えてしまっても問題ないかもしれません。
また、 @hmito さんよりコメントで教えていただいた関数ポインタをstd::functionっぽくラップアラウンドする方法がもしかすると私のユースケースではベターかも。
追記(後日談)
結果的に、
- 標準では
std::function
を使う(-O2指定を推奨) - オプショナルで関数ポインタを使う手段も残す
という形で対応することにしました。
(ドキュメントのこの辺り(英語)で詳述)
性能を多少犠牲にしても利便性を追求したいスタンス(性能追求ならmameとかのZ80実装の方が多分優秀で、そっちの路線のOSSが無かったから自前で作ったモノ)なので、男前に std::function
一本でも良かったかもしれませんが、既に幾つかのプロダクションコードで使っていることもあり安全策を取りました。
一瞬、std::function
ライクに扱える関数ポインタのラッパーという方式でも良いんじゃないか?とも思ったのですが、今回 std::function
への対応を検討するキッカケとなった issue で「ラムダで変数キャプチャしたいから関数ポインタじゃなくて std::function
にしてほしい」とのご意見を頂いていたので、思い直しました。
余談ですが、issueを挙げてくれた方のWebサイトを眺めたら中々趣のある感じでした。
http://www.julien-nevo.com/
さて、私のZ80エミュを使ってどんなモノを作ってくれるのでしょうか。
boost::function
なら、関数ポインタと関数オブジェクトを一本で管理でき、(boostのスタンスは基本性能追求だから)きっと関数ポインタ呼び出しと等価なシーンなら関数ポンタと同等のパフォーマンスを出せたりするかもしれません。(※諸々の事情でboostと依存関係を持ちたくなかったので未検証ですが)
boostのコード解析するのはちょっと億劫なので、調査してみようか若干躊躇っているところです。
ひとまず、機械的な自動振り分けの良い実装方法が考えつかなかったので、コチラの実装を参考にして、関数ポインタと std::function のどちらかへ明示的に振り分ける感じのクラスを準備しました。
template <typename T>
class CoExistenceCallback;
template <typename ReturnType, typename... ArgumentTypes>
class CoExistenceCallback<ReturnType(ArgumentTypes...)>
{
private:
ReturnType (*fp)(ArgumentTypes...);
std::function<ReturnType(ArgumentTypes...)> fc;
public:
CoExistenceCallback() { fp = nullptr; }
void setupAsFunctionObject(const std::function<ReturnType(ArgumentTypes...)>& fc_) { fc = fc_; }
void setupAsFunctionPointer(ReturnType (*fp_)(ArgumentTypes...)) { fp = fp_; }
inline ReturnType operator()(ArgumentTypes... args) { return fp ? fp(args...) : fc(args...); }
};
- 宣言例:
CoExistenceCallback<int(void*, const char*)> myCallback;
- 戻り値
int
で引数が(void*, const char*)
- 戻り値
-
std::function
を使いたい場合はsetupAsFunctionObject
で初期化 - 関数ポインタを使いたい場合は
setupAsFunctionPointer
で初期化 - 呼び出し例:
int ret = myCallback(arg1, "hoge");
実装を諦めた「機械的な自動振り分け」とは具体的には、コールバックを登録するメソッドやコンストラクタで、以下のようにオーバーロードして、利用者の指定方法に応じて 関数ポインタ or std::function
の適切な方に振り分けることです。
void setConsumeClockCallback(const std::function<void(void*, int)>& consumeClock_)
{
CB.consumeClockEnabled = true;
CB.consumeClock.setupAsFunctionObject(consumeClock_);
}
void setConsumeClockCallback(void (*consumeClock_)(void* arg, int clocks))
{
CB.consumeClockEnabled = true;
CB.consumeClock.setupAsFunctionPointer(consumeClock_);
}
しかし、上記は ambiguous (曖昧) ということでコンパイルエラーになります。
error: call to member function 'setConsumeClockCallback' is ambiguous
確かに、ラムダ式は関数ポインタに対してもキャプチャ無しなら指定できるため、引数にラムダ式を指定して setConsumeClockCallback
を呼び出した時、果たして関数オブジェクトと関数ポインタのどちらを期待しているのかは曖昧です。
という訳で自動振り分けは諦めて、「性能優先したいときは明示的にサフィックス FP
のメソッドでコールバック登録してね!」という仕様にしてます。
追記2(後日談2)
ambiguous (曖昧) でコンパイルエラーになった件で @hmito さんよりコメントでナイスな解決策をご提案いただきました。本当にありがとうございます
void setConsumeClockCallback(const stdfunctionvoid(void, int) consumeClock_)
templatetypename Functor>
void setConsumeClockCallback(Functor consumeClock_) { // 以下略
void setConsumeClockCallback(const stdfunctionvoid(void, int) consumeClock_)
templatetypename Functor>
void setConsumeClockCallback(Functor consumeClock_) { // 以下略