Boost 與 Qt 的 Signal / Slot 效能測試

| | 0 Comments| 17:14
Categories:

Qt 這個圖形介面的開發套件(官網),基本上算是目前數一數二的跨平台圖形介面開發方案了。

在 Qt 的架構中,要在不同的物件之間傳遞資訊、觸發事件,所使用的是其創造的「Signals & Slots」架構(官方文件)。

而目前除了 Qt 之外,還有許多函式庫,也有提供 Signals & Slots 的功能,像是 Boost C++ Libraries 裡面,就有 boost::signal 和 boost::signal2 這兩個函式庫可以使用;其中,Heresy 也有針對 boost::signal2 做過介紹(參考)。

在 Heresy 來看,Qt 的 signal & slot 的架構比較麻煩的地方,是他必須要使用許多非標準的語法,所撰寫的程式還需要經過 Qt meta-Object Compiler(moc)來做前處理,才能讓一般的 C++ 編譯器編譯。相較之下,Boost 的 Signal2 在撰寫上的彈性就比較大、也比較方便了。

不過,這兩者個效能怎麼樣呢?這邊 Heresy 就是打算來做個簡單的效能評估。

這邊  Heresy 的基本設想,是有兩個類別 Caller 和 Reciver,當使用者呼叫 Caller 的 run() 的函式時,要能去自動執行 Reciver 的 impl() 這個函式。


Qt Signal & Slot

以 Qt 標準的 Signal & Slot 寫法,Caller 的 class 大概會寫成下面的樣子:

class QCaller : public QObject
{
	Q_OBJECT
public:
	void run(int iVal)
	{
		emit invoke(iVal);
	}
signals:
	void invoke(int iVal);
};

而 Reciver 則會是:

class QReciver : public QObject
{
	Q_OBJECT
public slots:
	void impl(int iVal);
};

可以看到,不管是 Caller 或 Reciver,在程式中都需要加上一些關鍵字(紅字的部分)、讓 moc 在編譯前先去處理才行。

在使用時,則是要呼叫 QObject::connect() 這個函式,把 Signal 和 slot 做連結。

QCaller mCaller;
QReciver mReciver;
QObject::connect(&mCaller, &QCaller::invoke, &mReciver, &QReciver::impl);

如此一來,之後執行 mCaller.run() 的時候,就會自動去呼叫 mReciver.impl() 了。

而在 Qt5 推出時,新的 signal & slot 架構也同時允許 slot 是一般的函式(參考),而不一定需要是繼承 QObject 物件的 slot 函式;如此一來,他的通用性會更好一些,至少一般非 Qt 的 class 也可以拿來當作 reciver 用了。下面就是一個範例:

class BasicReciver
{
public:
	void impl(int iVal);
};

連接時,則可以寫成:

QCaller mCaller;
BasicReciver mReciver;
QObject::connect(&mCaller, &QCaller::invoke, [&mReciver](int iVal) {
	mReciver.impl(iVal);
});

不過,這樣做的缺點,就是當 mReciver 物件死亡時,他沒有辦法「自動斷線」了。(並非完全無解就是了)

另外,Qt 的 signal & slot 在連線時,其實也還有幾種不同的連線方法可以選擇,算是彈性相對大的。(參考


Boost Signal2

如果是以 Boost::Signal2 來寫的話,Caller 大概可以寫成:

class BSigCaller
{
public:
	void run(int iVal)
	{
		invoke(iVal);
	}
	boost::signals2::signal<void(int)> invoke;
};

而 Reciver 的部分,由於 Boost::Signal2 是設計用來連接一般的 callable object,所以基本上可以沿用前面的 BasicReciver

在使用時,則可以寫成:

BSigCaller mCaller;
BasicReciver mReciver;
mCaller.invoke.connect([&mReciver](int iVal) {
	mReciver.impl(iVal);
});

而如果希望他可以在 mReciver 消失時自己斷線的話,則可以搭配 shared_ptr 來使用(參考)。


std::function

而除了上面兩個方法外,其實還有許多方法,都可以實作出類似的功能;例如 C++11 的 std::function<> 就是一個可以拿來實作簡易型的 callback 的機制。

(這邊不提 function pointer 是因為他要處理 class 的 member function 非常麻煩…)

在考慮到可能要觸發多個 reciver 的情況下,這邊的 Caller 可以簡單寫成:

class CfuncCaller
{
public:
	void run(int iVal)
	{
		for( auto& rFunc: m_vReciver)
			rFunc(iVal);
	}
	std::vector<std::function<void(int)>> m_vReciver;
};

Reciver 的部分,這邊一樣是沿用前面的 BasicReciver

而要使用時,則只要把 reciver 的函式用 lambda 包起來、丟到 vector 裡面就可以了。

CfuncCaller mCaller;
BasicReciver mReciver;
mCaller.m_vReciver.push_back( [&mReciver](int iVal) {
	mReciver.impl(iVal);
} );

這樣的實作由於都是使用標準函式庫的東西,所以其實相當簡單。

不過相對的,他個功能也更為簡單,基本上也沒有什麼自動斷線之類的功能了。


當作成員物件、直接呼叫

另外,根據之前《C++ 幾種函式傳遞方法的效能比較》的經驗,std::function<> 的效率其實並不好!所以這邊就另外設計了一個更單純的機制,就是把 reciver 的指標直接存在 caller 內、直接去呼叫,理論上這樣的效能應該會是接近最佳的。其寫法如下:

class MemCaller
{
public:
	void run(int iVal)
	{
		for (auto pRec : m_vReciver)
			pRec->impl(iVal);
	}
	std::vector<BasicReciver*> m_vReciver;
};

Reciver 的部分,這邊一樣是沿用前面的 BasicReciver

使用實則是:

MemCaller mCaller;
BasicReciver mReciver;
mCaller.m_vReciver.push_back( &mReciver );

而實際上,它的功能也相當少,如果能接受不同的 reciver,實際上就需要透過繼承 BasicReciver、並將 impl() 改成 virtual function、並 override 掉了;而這部分,實際上也是會對效能造成影響的。


效能測試

這邊完整的程式碼,Heresy 都放在 GitHub 上(連結)了。由於主要是要測試 Boost 和 Qt,所以這邊就要用 qmake 了。

Heresy 自己在 Visual C++ 2015 和 g++ 4.8.4 都做了測試,執行次數是 100,000,000、實際執行的都是簡單的累加計算;在設定上,基本上都只有使用預設的最佳化參數,並沒有很認真地去排除編譯器的最佳化的不同。


在 VC2015 的環境時,其結果如下(數字單位為 ms):

Member data
std::function
Boost signal2
Qt5 Signal & Slot
non virtual
virtual
Qt Slot
simple function
1 slot
87
780
1201
16845
7587
7636
5 slot
215
3663
5558
42771
24281
24723
10 slot
606
7732
11464
75637
45266
45874

如果把各種方法的效能,都和第一項做比較的話,其結果則是(數字代表倍數):

Member data
std::function
Boost signal2
Qt5 Signal & Slot
non virtual
virtual
Qt Slot
simple function
1 slot
1.00
8.97
13.80
193.62
87.21
87.77
5 slot
1.00
17.04
25.85
198.93
112.93
114.99
10 slot
1.00
12.76
18.92
124.81
74.70
75.70

可以看到,當有使用 virtual 後,所需要的時間就已經馬上增加了快九倍以上,而使用 std::function 也需要超過 10 倍、甚至 20 倍的時間。

而 Qt 的 Signal & slot 大概會需要 75 倍以上的時間、Boost 的 Signal2 則更需要到 125 倍以上…感覺上,這邊額外的效能負擔,如果有需要大量使用的話,似乎已經超過可以忽視的範圍了?

而如果是看單一 signal 所對應 slot 越來越多時造成的影響呢?下面則是把 1 slot 當作比較基準的結果(數字代表倍數):

Member data
std::function
Boost signal2
Qt5 Signal & Slot
non virtual
virtual
Qt Slot
simple function
1 slot
1.00
1.00
1.00
1.00
1.00
1.00
5 slot
2.47
4.70
4.63
2.54
3.20
3.24
10 slot
6.97
9.91
9.55
4.49
5.97
6.01

可以看到,Boost::signal2 在 slot 增加時,所增加的時間算是相對少的;但是由於他一開始就吃太多了,所以感覺還是不是很划算啊…


而如果是使用 g++ 4.8.4 的話,測試的結果如下:

Member data
std::function
Boost signal2
Qt5 Signal & Slot
non virtual
virtual
Qt Slot
simple function
1 slot
602
747
745
13209
6586
6639
5 slot
3041
3726
5045
30680
20017
20034
10 slot
6047
7873
10478
51717
37558
37990

這邊比較有趣的,是在 g++ 中,non virtual 的類別的效率雖然還是比有 virtual 的類別來的好,但是快的並不多;這點,應該是編譯器最佳化造成的問題吧?

去掉這點的話,其他的效能算是差不多。

如果把各種方法的效能,都和第一項做比較的話,其結果則是(數字代表倍數):

Member data
std::function
Boost signal2
Qt5 Signal & Slot
no virtual
virtual
Qt Slot
simple function
1 slot
1.00
1.24
1.24
21.94
10.94
11.03
5 slot
1.00
1.23
1.66
10.09
6.58
6.59
10 slot
1.00
1.30
1.73
8.55
6.21
6.28

下面則是把 1 slot 當作比較基準的結果(數字代表倍數):

Member data
std::function
Boost signal2
Qt5 Signal & Slot
no virtual
virtual
Qt Slot
simple function
1 slot
1.00
1.00
1.00
1.00
1.00
1.00
5 slot
5.05
4.99
6.77
2.32
3.04
3.02
10 slot
10.04
10.54
14.06
3.92
5.70
5.72

最後,以結論來說的話…看來如果是要大量使用的話,可能還是得避開 Boost 或 Qt 的 signal & slot 吧…畢竟,他們的 overhead 算是大到不可忽略的了…

所以,如果是要連動的物件數量很多、而且呼叫很頻繁的話,還是想辦法自己用簡單的方法來時做吧;如果在需求單純的情況下,就算是簡單地用 std::function 來實作,都可以獲得好上數倍的效能。

當然,自己實作會欠缺很多功能,安全性上或許也不是那麼好,但是那些功能、額外的檢查,也都可能會是產生效能瓶頸的地方啊…

這也難怪會有人說,真的在乎效能的話,就把這些寫的大的函式庫丟掉了。(苦笑

(有興趣可以參考《SG14 (the GameDev & low latency ISO C++ working group) – Guy Davidson – Meeting C++ 2016》)


其他參考:

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *