Język C++ jest często wybierany, gdy wydajność projektowanego systemu ma kluczowe znaczenie. Niestety samo jego użycie nie zagwarantuje, że osiągnięta zostanie założona szybkość przetwarzania. Dlatego coraz większy nacisk kładzie się na optymalizację kodu źródłowego i na czas jego wykonywania. Jednym z aspektów, który ma wpływ na wydajność, jest ilość kopiowanych danych.
W niniejszym artykule skupię się na liczbie kopii obiektów wykonywanych przez bardzo popularną bibliotekę Qt w wersji 6.2.4, a dokładnie na jej mechanizmie zwanym „Signal & Slot”. Przedstawię również porównanie z własną implementacją stworzonej przeze mnie biblioteki TBC (Template Based Communication).
Biblioteka QT
Mechanizm „Signal & Slot” w Qt służy do synchronicznej lub asynchronicznej komunikacji między obiektami. Aby móc z niego korzystać, obiekty muszą dziedziczyć po klasie QObject oraz użyć makro „Q_OBJECT” w swojej deklaracji. Obiekt wysyłający wiadomość deklaruje sygnał wraz z argumentami, które muszą być kopiowalne:
class Sender : public QObject {
Q_OBJECT
signals:
void customSignal(int value);
};
Obiekt odbierający deklaruje odpowiadający slot:
class Receiver : public QObject {
Q_OBJECT
public slots:
void customSlot(int value);
};
Następnie, obiekty należy połączyć przez wykonanie metody statycznej QObject::connect:
QObject::connect(&senderObject,
&Sender::customSignal,
&receiverObject,
&Receiver::customSlot);
Metoda ta przyjmuje również opcjonalny parametr, który definiuje typ połączenia. Skupię się na dwóch najważniejszych:
- Qt::DirectConnection – połączenie bezpośrednie. Metoda zdefiniowana jako slot zostanie wykonana synchronicznie w tym samym wątku, tak jakby została bezpośrednio wywołana jako slot.
- Qt::QueuedConnection – połączenie kolejkujące. Metoda zdefiniowana jako slot zostanie wykonana asynchronicznie:
- W tym samym wątku, gdy wróci do głównej pętli Qt i to wywołanie będzie następne w kolejce.
- W innym wątku, gdy zostanie wcześniej wykonana metoda QObject::moveToThread na obiekcie odbierającym.
Szczegóły tego mechanizmu są zawarte w dokumentacji Qt. Powyżej opisana funkcjonalność wydaje się bardzo użyteczna i łatwa w użyciu.
W dalszej części artykułu skupię się na sprawdzeniu liczby tworzonych kopii tylko w połączeniu kolejkującym. Napisałem prosty test przesyłający obiekt, który zlicza kopie poprzez inkrementację zmiennej statycznej w konstruktorze kopiującym. Poniżej znajduje się definicja tej klasy:
namespace
{
static int copyCounter = 0;
}
class Msg {
public:
Msg() = default;
Msg(const Msg&) {
++::copyCounter;
}
Msg(Msg&&) = default;
int copyCounter() const {
return ::copyCounter;
}
};
Po dostarczeniu parametru do slotu, liczba kopii zostaje wypisana na konsolę. Wyniki tego testu są przedstawione w poniższej tabeli:
Typ parametru w sygnale | Typ argumentu w slocie | Liczba wykonanych kopii |
const Msg& | const Msg& | 1 |
const Msg& | Msg | 2 |
Msg&& | const Msg& | 1 |
Msg&& | Msg | 2 |
Jak można zaobserwować, w przypadku połączenia kolejkującego liczba wykonanych kopii jest zależna od typu parametru w sygnale i argumentu w slocie. Należy zwrócić uwagę, że w dwóch ostatnich przypadkach parametr podawany jest przez rvalue. Obciążenie wydajnościowe wynikające z tych kopii przedstawię w osobnym rozdziale.
Biblioteka TBC
Dokumentacja Qt wspomina, że biblioteka musi wykonywać kopie obiektów, aby przechowywać je „za kulisami”, ale nigdzie nie definiuje, ile kopii jest wykonanych dla różnych typów argumentów. Moim celem jest udowodnienie, że liczbę kopii dla połączenia kolejkującego można zminimalizować, wykorzystując semantykę przenoszenia lub poprzez zagwarantowanie przez użytkownika, że referencja obiektu będzie ważna w momencie wywołania slotu. W tym celu napisałem swoją własną bibliotekę TBC (Template Based Communication).
Aby uniknąć konieczności tworzenia własnego metajęzyka, skorzystałem z mechanizmu szablonów, dzięki któremu mogę zdefiniować jakie argumenty będą przesyłane. Obiekt wysyłający musi dziedziczyć po klasie TBC::Sender<T>, natomiast obiekt odbierający po klasie TBC::Receiver<T>, gdzie „T” jest typem wysyłanego argumentu. Sygnał wysyłany jest na dwa sposoby:
- valueSignal(T ) – sygnał przyjmuje argument przez wartość, co pozwala na zastosowanie semantyki przenoszenia.
- constRefSignal(const T& ) – sygnał przyjmuje stałą referencje do obiektu. Obiekt zostanie skopiowany jedynie wtedy, gdy slot przyjmuje argument jako wartość. Wysyłany obiekt nie może zostać zniszczony przed wywołaniem połączonego slotu.
Analogicznie obsługiwany jest odbiór argumentów poprzez valueSlot(T ) i constRefSlot(const T& ). Przykład użycia w kodzie jest zaprezentowany poniżej:
class Sender : public TBC::Sender<LargeObj> {
public:
void sendValue(LargeObj value) {
valueSignal(std::move(value));
}
void sendconstRef(const LargeObj& ref) {
constRefSignal(ref);
}
};
class Receiver : public TBC::Receiver<LargeObj> {
public:
void valueSlot(LargeObj value) override {}
void constRefSlot(const LargeObj& ref) override {}
};
Aby połączyć obiekty można użyć statycznej metody TBC::connect:
TBC::connect(&sender, &receiver);
Po szczegóły odsyłam do mojego repozytorium, gdzie można znaleźć implementację biblioteki TBC, diagram klas oraz testy funkcjonalne i wydajnościowe. Wszystkie komentarze i uwagi mile widziane 🙂
Tak jak w przypadku Qt, przeprowadziłem analogiczne testy w celu zbadania liczby wykonanych kopii dla połączenia kolejkującego. Wyniki testów znajdują się w poniższej tabeli:
Typ parametru w sygnale | Typ argumentu w slocie | Liczba kopii |
const Msg& | const Msg& | 0 |
const Msg& | Msg | 1 |
Msg&& | const Msg& | 0 |
Msg&& | Msg | 0 |
Można zauważyć, że biblioteka TBC zapewnia minimalną liczbę wymaganych kopii dla połączenia kolejkującego.
Testy wydajnościowe
Przeprowadzone testy wydajnościowe mierzyły czas, jaki upłynął od emisji sygnału do wywołania slotu, który działa w osobnym wątku, dla różnych rozmiarów przesyłanego parametru. Każdy test wydajnościowy wykonywał 50 iteracji. Testy zostały uruchomione dwukrotnie, dając łącznie 100 pomiarów, z których obliczono średni czas operacji.
Konfiguracja systemu, na którym przeprowadzono testy, była następująca:
- System operacyjny: Ubuntu 22.04.2 LTS
- Kompilator: gcc 11.3.0
- Wersja biblioteki Qt: 6.2.4
Pierwszy test wydajnościowy mierzył przesłanie obiektu typu std::chrono::high_resolution_clock::time_point ustawionego zaraz przed emisją sygnału oraz analizował opóźnienia w slocie odbiorcy. Dla zobrazowania, fragment kodu korzystającego z Qt:
QObject::connect(&sender, &QTSender::send, &receiver, &QTReceiver::valueSlot, Qt::QueuedConnection);
newThread.start();
std::cout << "QT latency [µs]: ";
for (size_t i = 0; i < iterations; ++i) {
sender.send(std::chrono::high_resolution_clock::now());
std::this_thread::sleep_for(std::chrono::seconds{1});
}
std::cout << std::endl;
public slots:
void valueSlot(std::chrono::high_resolution_clock::time_point sendTimePoint) {
auto endTimePoint = std::chrono::high_resolution_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds> (endTimePoint - sendTimePoint).count() << ",";
}
oraz TBC:
TBC::connect(&sender, &receiver);
receiver.runInNewThread();
std::cout << "TBC latency [µs]: ";
for (size_t i = 0; i < iterations; ++i) {
sender.valueSignal(std::chrono::high_resolution_clock::now());
std::this_thread::sleep_for(std::chrono::seconds{1});
}
std::cout << std::endl;
public:
void valueSlot(std::chrono::high_resolution_clock::time_point sendTimePoint) override {
auto endTimePoint = std::chrono::high_resolution_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds> (endTimePoint - sendTimePoint).count() << ",";
}
Wyniki testu są zobrazowane poniżej:
Chociaż biblioteka TBC wykazuje około 10% mniejsze opóźnienie względem biblioteki Qt, to nie jest to znacząca różnica, ponieważ dopiero po 100 tysiącach operacji opóźnienie zsumuje się do 1 sekundy.
Dalsze testy wydajnościowe
Kolejne testy wydajnościowe mierzyły czas potrzebny na przesłanie parametru Msg, który zawiera tablicę bajtów o zmiennej długości, przy czym rozmiar tej tablicy był mnożony czterokrotnie, zaczynając od 1kB, aż do 256MB. Kod źródłowy klasy Msg zaprezentowano poniżej:
class Msg {
std::vector<uint8_t> _data;
std::chrono::high_resolution_clock::time_point _msgCreationTimePoint;
public:
Msg() = default;
Msg(size_t msgByteSizeInKb) :
_data(msgByteSizeInKb * 1024),
_msgCreationTimePoint{std::chrono::high_resolution_clock::now()}
{}
Msg(const Msg& other) = default;
Msg(Msg&& other) = default;
void resetCreationTimePoint() {
_msgCreationTimePoint = std::chrono::high_resolution_clock::now();
}
const std::chrono::high_resolution_clock::time_point& sendTimePoint() const {
return _msgCreationTimePoint;
}
size_t dataSizeInKb() const {
return _data.size() / 1024;
}
static constexpr int maxMsgSizeInKb = power(8, 6);
};
Testy wykonano dla wszystkich kombinacji typów parametru sygnału i argumentu slotu, które zostały przedstawione w tabelach w poprzednich sekcjach. Poniżej znajdują się wykresy stworzone na podstawie osiągniętych wyników:
Wykresy pokazują, że czas wykonania operacji przez Qt w każdym przypadku jest liniowo zależny od rozmiaru parametru, a czasy korelują z wcześniej deklarowanymi wartościami liczby wykonywanych kopii. W przypadku TBC, dla trzech sytuacji, w których można uniknąć kopii obiektu, wykazuje on stałą złożoność, co oznacza, że czas przesłania danych jest niezależny od ich wielkości.
Podsumowanie
Testy wydajnościowe potwierdziły, że biblioteka Qt (w wersji 6.2.4) nieoptymalnie zarządza pamięcią w przeprowadzonych testach. Niestety, w dokumentacji Qt nie ma jasnej wzmianki o takim ograniczeniu wydajnościowym. Stworzona przeze mnie biblioteka TBC udowadnia, że można osiągnąć optymalną liczbę operacji kopiowania obiektów, zachowując analogiczny i przyjazny interfejs. Możliwe, że w kolejnych wersjach Qt otrzymamy aktualizacje dodającą wparcie operacji przenoszenia parametrów.
Tymczasem, używając mechanizmu „Signal & Slot” z Qt:
- Należy pamiętać, że obiekt musi być kopiowalny i zostanie on skopiowany przynajmniej raz przy emisji sygnału w trybie kolejkującym.
- Preferowane jest przyjmowanie parametru w slocie jako stałej referencję, dzięki czemu uniknie się wykonania jednej kopii.
- Jeśli jest taka możliwość, warto zapakować parametr w std::shared_ptr, aby uniknąć kopiowania obiektu.
Przy korzystaniu ze std::shared_ptr, należy pamiętać, że parametr wewnątrz zostanie zniszczony w momencie usunięcia ostatniej kopii std::shared_ptr, chyba że ustawiona zostanie inna funkcja destrukcji obiektu, tzw. „custom deleter”. Najbezpieczniej jest użyć metody std::make_shared na etapie alokacji pamięci, aby nie wywołać destruktora przechowywanego obiektu i nie zwolnić jego pamięci drugi raz.
Bibliografia
- Burkhard Stubert “Copied or Not Copied: Arguments in Signal-Slot Connections?”
- Signals & Slots
- Repozytorium – Karol Sierociński
***
Jeśli interesuje Cię tematyka C++, zajrzyj również do innych artykułów naszych ekspertów.
Zostaw komentarz