Table of contents
Open Table of contents
Background
Conduct a comparative research on the C++ runtime polymorphism libraries boost-ext/te
, folly/Poly
, and microsoft/proxy
from the perspectives of 1. Performance 2. Functionality 3. Ease of use 4. Other.
Objective:
To achieve better code flexibility, performance, and composability. Possible Use Cases:
- Quant Trading Risk Control Module
- Quant Trading Execution Module
Explanation:
The purpose of these libraries is to implement runtime polymorphism in a non-intrusive manner. It eliminates the need to write functions with the same function names and parameters as virtual functions, and there is no need to modify those classes to inherit from others.
Predefinitions
#include <iostream>
// Implementations (No base class)
class Rectangle {
public:
Rectangle(double width = 1.0, double height = 1.0)
: width_(width), height_(height) {}
void Draw(std::ostream& out) const {
out << "{Rectangle: width = " << width_ << ", height = " << height_ << "}";
}
void SetWidth(double width) { width_ = width; }
void SetHeight(double height) { height_ = height; }
double Area() const { return width_ * height_; }
private:
double width_;
double height_;
};
class Circle {
public:
Circle(double radius = 1.0) : radius_(radius) {}
void Draw(std::ostream& out) const {
out << "{Circle: radius = " << radius_ << "}";
}
void SetRadius(double radius) { radius_ = radius; }
double Area() const { return 3.14 * radius_ * radius_; }
private:
double radius_;
};
class Point {
public:
void Draw(std::ostream& out) const { out << "{Point}"; }
constexpr double Area() const { return 0; }
};
microsoft/proxy
// Abstraction
struct Draw : pro::dispatch<void(std::ostream &)> {
template <class T>
void operator()(const T &self, std::ostream &out) {
self.Draw(out);
}
};
struct Area : pro::dispatch<double()> {
template <class T>
double operator()(const T &self) {
return self.Area();
}
};
then, using pro::facade
:
struct DrawableFacade : pro::facade<Draw, Area> {};
then, we can use facade
to write generic code:
// Client - Consumer
std::string PrintDrawableToString(pro::proxy<DrawableFacade> p) {
std::stringstream result;
result << "shape = ";
p.invoke<Draw>(result); // Polymorphic call
result << ", area = " << p.invoke<Area>(); // Polymorphic call
return std::move(result).str();
} // Client - Producer
pro::proxy<DrawableFacade> CreateRectangleAsDrawable(int width, int height) {
Rectangle rect;
rect.SetWidth(width);
rect.SetHeight(height);
return pro::make_proxy<DrawableFacade>(
rect); // No heap allocation is expected
}
then, conveniently in our code, we can do:
int main() {
auto rec = CreateRectangleAsDrawable(10, 20);
std::cout << PrintDrawableToString(std::move(rec)) << std::endl;
return 0;
}
Moreover, the proxy
library supports lifecycle management in combination with smart pointers, and the uniqueness of this lifecycle management approach in proxy
is unparalleled. While some libraries claim to support various lifecycle management models, they do not allow for the customization of each object as proxy
does.
therefore, we can use a factory generator:
pro::proxy<DrawableFacade> MakeDrawableFromCommand(const std::string& s) {
std::vector<std::string> parsed = ParseCommand(s);
if (!parsed.empty()) {
if (parsed[0u] == "Rectangle") {
if (parsed.size() == 3u) {
static std::pmr::unsynchronized_pool_resource rectangle_memory_pool;
std::pmr::polymorphic_allocator<> alloc{&rectangle_memory_pool};
auto deleter = [alloc](Rectangle* ptr) mutable {
alloc.delete_object<Rectangle>(ptr);
};
Rectangle* instance = alloc.new_object<Rectangle>();
std::unique_ptr<Rectangle, decltype(deleter)> p{instance, deleter};
p->SetWidth(std::stod(parsed[1u]));
p->SetHeight(std::stod(parsed[2u]));
return p; //implicit conversion occurs
}
} else if (parsed[0u] == "Circle") {
if (parsed.size() == 2u) {
Circle circle;
circle.SetRadius(std::stod(parsed[1u]));
return pro::make_proxy<DrawableFacade>(circle); //Small Buffer Optimization could happen
}
} else if (parsed[0u] == "Point") {
if (parsed.size() == 1u) {
static Point instance; // global singleton
return &instance;
}
}
}
throw std::runtime_error{"Invalid command"};
}
-
If we use IDrawable*, the semantics of the return type are ambiguous because it is a raw pointer type and does not indicate the lifecycle of the object. For instance, it could be allocated through operator new, or via a memory pool or a global object.
-
If we use std::unique_ptr
, it implies that each individual object is allocated from the heap, even if the value might be immutable or reusable, which could be detrimental to performance. -
If we use std::shared_ptr
, the performance of flyweight objects may become more favorable due to the relatively low cost of copying, but the ownership of the objects becomes vague (also known as “ownership hell”), and the thread safety guarantees of std::shared_ptr during copy construction and destruction might add runtime overhead. On the other hand, if we prefer std::shared_ptr throughout the system, it encourages every polymorphic type to inherit from std::enable_shared_from_this, which may significantly impact the design and maintenance of large systems.
and then, we can:
int main() {
pro::proxy<DrawableFacade> p = MakeDrawableFromCommand("Rectangle 2 3");
std::string s = PrintDrawableToString(std::move(p));
puts(
s.c_str()); // prints: "shape = {Rectangle: width = 2.00000, height // = 3.00000}, area = 6.00000"
p = MakeDrawableFromCommand("Circle 1");
s = PrintDrawableToString(std::move(p));
puts(
s.c_str()); // prints: "shape = {Circle: radius = 1.00000}, area // = 3.14159"
p = MakeDrawableFromCommand("Point");
s = PrintDrawableToString(std::move(p));
puts(s.c_str()); // prints: "shape = {Point}, area = 0.00000"
try {
p = MakeDrawableFromCommand("Triangle 2 3");
} catch (const std::runtime_error& e) {
puts(e.what()); // prints: "Invalid command"
}
return 0;
}
boost-ext/te
first, we define:
namespace te = boost::te; // Define interface of something which is drawable
struct Drawable {
void draw(std::ostream& out) const {
te ::call([](auto const& self, auto& out) { self.Draw(out); }, *this, out);
}
double area() const {
double res = 0.0;
te ::call([](auto const& self, double& res) { res = self.Area(); }, *this,
res);
return res;
}
};
then, we can:
// Client - Consumer
std::string PrintDrawableToString(te ::poly<Drawable> const& drawable) {
std::stringstream result;
result << "shape = ";
drawable.draw(result); // Polymorphic call
result << ", area = " << drawable.area(); // Polymorphic call
return std::move(result).str();
}
int main() { // naive tests:
std::cout << PrintDrawableToString(Rectangle{}) << std::endl;
std::cout << PrintDrawableToString(Circle{}) << std::endl;
}
Note that this library cannot use factory functions like the proxy
library mentioned above, as it does not support other lifecycle management models and cannot be wrapped in unique_ptr
or shared_ptr
. Doing so would result in the corresponding Draw
and Area()
functions becoming inaccessible. This library can only use the default lifecycle: a te::poly<Drawable>
is created and then destroyed upon exiting its scope.
folly/Poly
first we define:
// This example is an adaptation of one found in Louis Dionne's dyno library.
#include <folly/Poly.h>
#include <iostream>
#include "Shapes.h"
#include "utils.h"
struct IDrawable { // Define the interface of
// something that can be
// drawn:
template <class Base>
struct Interface : Base {
void Draw(std::ostream& out) const { folly::poly_call<0>(*this, out); }
}; // Define how concrete types can fulfill that interface (in C++17):
template <class T>
using Members = folly::PolyMembers<&T ::Draw>;
}; // Define an object that can hold anything that can be drawn:
using drawable = folly::Poly<IDrawable>;
then, we can:
void f(drawable const& d) { d.Draw(std::cout); }
int main() {
f(Rectangle{}); // prints Square
f(Circle{}); // prints Circle
}
folly/Poly
also has small object optimization, which will store “small” objects in an internal buffer, avoiding the cost of dynamic allocation. Currently, this size is not configurable; it is fixed to the size of two doubles.folly/Poly
objects are always moveable without exception. If you store an object with a moveable constructor that could throw a move constructor in one of them, the object will be stored on the heap, even if it can fit into thePoly
object’s internal storage. (So make sure you provide exception-free move constructors for your objects!)folly/Poly
implements type erasure in a way very similar to how a compiler implements virtual dispatch. EachPoly
object contains a pointer to a table of function pointers. Member function calls involve double indirection: one indirect function call via the v pointer and another via the function pointer.
Performance Comparisons
library/time(ns) | 0th | 50th | 75th | 90th | 95th | 99th | 99.999th | 100th |
---|---|---|---|---|---|---|---|---|
proxy | 379 | 399 | 409 | 429 | 439 | 499 | 245205 | 406061 |
te | 380 | 430 | 450 | 470 | 480 | 810 | 289762 | 444824 |
folly | 360 | 370 | 380 | 400 | 410 | 790 | 248242 | 353563 |
Native C++ (Non-Polymorphic) | 360 | 370 | 380 | 400 | 410 | 430 | 154920 | 281170 |
Native C++ (Polymorphism with Inheritance) | 359 | 379 | 379 | 409 | 419 | 429 | 150878 | 298877 |
All three libraries have similar performance. All three libraries are Native C++ (Non-Polymorphic)
> Native C++ (Polymorphism with Inheritance)
> polymorphic libraries
, but none are much faster. Based on averages, it’s roughly folly
: 387 < 391 < 396
; te
: 435 < 439 < 451
; proxy
: 412 < 420 < 421
. (nanoseconds)
For accuracy, these are the results after poisoning the L1 cache before each run, all from calling a Draw()
and then an Area()
into a stringstream. Also, all system soft interrupts were bound to core 0
using taskset on the test machine, and these tests were done on core 1
. (Rocky Linux 9.2, AMD 7950X
, same configuration as the live trading machine we use for quantitative trading)
Functionality
The proxy
library supports lifecycle management in conjunction with smart pointers. proxy
is unique in this lifecycle management highlight. Some libraries claim to support various lifecycle management models, but do not allow per-object customization like proxy
.
Ease of use:
As seen in the code, all three libraries require about the same amount of code to implement the desired functionality
Flexibility
te
and proxy
are single header files and can be easily integrated into existing projects, Poly
is not single file and requires the installation of the folly
library, which also relies on boost, glog, etc., which is more cumbersome to install.
technological realization
The underlying principle is similar