!!! ВНИМАНИЕ, БЫЛА ПРОИЗВЕДЕНА ПЕРЕПРОВЕРКА ЗАМЕРОВ
Я провёл повторные замеры на корректно сконфигурированной сборке и с ужасом обнаружил, что реализация на Ref проигрывает ~20–30 % в end-to-end-тесте. Флеймграф показал, что вызовы через Ref::_vtable не инлайнатся, и лишняя
индирекция съедает предполагаемую выгоду. Первоначальные цифры были получены из-за некорректной конфигурации. Статья остаётся в блоге как fail case, пример того, как попытка переиграть компилятор оборачивается регрессом. Если вам критична сырая задержка, используйте обычные virtual; Ref может быть интересен только в сценариях, где скорость не на первом месте. Спасибо всем, кто сомневался и задавал вопросы, именно эти сомнения помогли найти ошибку.
Я занимаюсь разработкой С++ фреймворка для построения торговых систем. Идейно, он предоставляет строительные блоки, на основе которых можно реализовать свой сборщик маркет‑данных, торговую стратегию, систему маркет‑мейкера или любой вспомогательный сервис.
Месяц назад я выложил первую минорную версию и отправился собирать фидбэк на профильных ресурсах. Отклик оказался живым, проект оброс новыми сущностями и ближе подошёл к требованиям индустрии.
Одним из вопросов был: «почему так много virtual?».
Действительно, при проектировании я выбрал классическое ОО‑наследование с виртуальными функциями, ради скорости прототипирования и читаемости иерархий. К тому же для некоторых подсистем (например, коннекторы) фреймворк оставляет только интерфейсы, а реализации полностью отдаёт на сторону пользователя.
Но у каждой абстракции есть своя цена. В случае виртуальных таблиц это ухудшение кеш‑локальности, рост промахов кеша и дополнительные проблемы с предсказателем переходов. Мне требовалась альтернатива, которая реально улучшала бы ключевые метрики. Доказательством улучшения должны были стать замеры на демо‑приложении, включённом в репозиторий.
Первая идея, которая пришла мне в голову - монотипы с ручными vtable. Это не новый приём, его можно встретить в folly::poly, LLVM и Unreal Engine. До C++20 у подхода был главный минус, слабая типовая безопасность. Для решения этой проблемы я решил использовать концепты.
Каждая сущность в новой модели описывается триадой «концепт, трейт, хендл». Концепт формулирует требования, трейт генерирует статическую vtable, а хендл, как монотип, играет роль универсальной полиморфной ссылки. Но где хранить конкретный объект?
Первая реализация и проблема «протухающих» view
В первом варианте реализации я поделил хранение на две политики: либо объект лежит во внутреннем буфере хендла, если влезает, либо аллоцируется во внешней памяти и передаётся в хендл снаружи. Всё шло хорошо, пока не возникла необходимость в view - невладеющем дескрипторе на тот же объект. Я хотел обращаться с хендлом как с value‑типом, чтобы прозрачно контролировать время жизни, но при inline‑хранении после каждого перемещения view терял актуальность. Например, шины данных должны были получать view на стратегии, чтобы передавать в них рыночные события, но при текущем подходе это не представлялось возможным.
Итерация 2: fat pointer + аллокатор
Для решения описанной проблемы в итоге я отказался от внутреннего буфера. Теперь каждый хендл содержит using Allocator = ...,
а создание идёт через фабричную функцию, которая знает, как этот Allocator использовать. Сейчас в качестве аллокатора используется простой фри‑лист. Сам хендл я назвал Ref
: по сути это fat-pointer - пара void* + указатель на vtable.
Реализация Ref
template <typename Trait>
class Ref {
public:
using VTable = typename Trait::VTable;
template <typename Impl>
static Ref from(Impl* ptr) {
static constexpr VTable vt = Trait::template makeVTable<Impl>();
return Ref{ptr, &vt};
}
template <typename T> T& get() const { return *static_cast<T*>(_ptr); }
const VTable* vtable() const noexcept { return _vtable; }
void* raw() const noexcept { return _ptr; }
private:
Ref(void* p, const VTable* v) : _ptr(p), _vtable(v) {}
void* _ptr{};
const VTable* _vtable{};
};
Как выглядят трейт и vtable
Внутри Flox VTable
это constexpr-структура, в которой каждый элемент - обычный указатель на функцию. Для любого метода интерфейса хелпер meta::wrap<&T::method>()
порождает свободную функцию вида R (*)(void* self, Args...)
. Этот метод статически каррирует this: внутри выполняется static_cast<T*>(self)->method(args...)
, что превращает метод класса в C-style функцию нужной сигнатуры.
Пример простейшей триады, описывающей тип подсистемы
template <typename T>
concept Subsystem = requires(T t) {
{ t.start() } -> std::same_as<void>;
{ t.stop() } -> std::same_as<void>;
};
struct SubsystemTrait {
struct VTable {
void (*start)(void*);
void (*stop)(void*);
};
template <typename T>
requires concepts::Subsystem<T>
static constexpr VTable makeVTable() {
return { meta::wrap<&T::start>(), meta::wrap<&T::stop>() };
}
};
class SubsystemRef : public RefBase<SubsystemRef, SubsystemTrait> {
public:
using RefBase::RefBase;
void start() const { _vtable->start(_ptr); }
void stop() const { _vtable->stop(_ptr); }
};
Агрегация трейтов: когда одного интерфейса мало
Полиморфизм между несколькими интерфейсами реализован композицией таблиц. Составная vtable хранит адрес вложенной таблицы, фактически вкладывая один интерфейс в другой. Поскольку Ref<Trait>
это простая пара {void* object, const VTable* table}
, достаточно вернуть вложенный указатель через SomeTrait::VTable::as<OtherTrait>()
, чтобы та же самая память интерпретировалась как Ref<OtherTrait>
без копирования или преобразований. Такое соглашение действует во всех трейтах и унифицирует переход между слоями абстракции по всему фреймворку.
Ниже фрагмент, показывающий, как MarketDataSubscriberTrait
объединяет базовый SubscriberTrait
с методами для тиковых событий. Благодаря полю subscriber
внутри собственной vtable
ссылка легко понижает себя до базового интерфейса без лишней логики.
Реализация MarketDataSubscriberTrait
template <typename T>
concept MarketDataSubscriber =
Subscriber<T> &&
requires(T t, const BookUpdateEvent& b, const TradeEvent& tr, const CandleEvent& c) {
{ t.onBookUpdate(b) } -> std::same_as<void>;
{ t.onTrade(tr) } -> std::same_as<void>;
{ t.onCandle(c) } -> std::same_as<void>;
};
struct MarketDataSubscriberTrait {
struct VTable {
const SubscriberTrait::VTable* subscriber;
void (*onBookUpdate)(void*, const BookUpdateEvent&);
void (*onTrade)(void*, const TradeEvent&);
void (*onCandle)(void*, const CandleEvent&);
template <typename Trait>
const typename Trait::VTable* as() const {
if constexpr (std::is_same_v<Trait, SubscriberTrait>)
return subscriber;
static_assert(sizeof(Trait) == 0, "Trait not supported");
}
};
template <typename T>
requires concepts::MarketDataSubscriber<T>
static constexpr VTable makeVTable() {
static constexpr auto sub = SubscriberTrait::makeVTable<T>();
return {
&sub,
meta::wrap<&T::onBookUpdate>(),
meta::wrap<&T::onTrade>(),
meta::wrap<&T::onCandle>()
};
}
};
class MarketDataSubscriberRef : public RefBase<MarketDataSubscriberRef, MarketDataSubscriberTrait> {
public:
using RefBase::RefBase;
SubscriberId id() const { return _vtable->subscriber->id(_ptr); }
SubscriberMode mode() const { return _vtable->subscriber->mode(_ptr); }
void onTrade(const TradeEvent& ev) const { _vtable->onTrade(_ptr, ev); }
void onBookUpdate(const BookUpdateEvent& e) const { _vtable->onBookUpdate(_ptr, e); }
void onCandle(const CandleEvent& ev) const { _vtable->onCandle(_ptr, ev); }
};
Цифры, к которым всё велось
Если совсем коротко: +19% к количеству обрабатываемых событий на демо за то же время (30 секунд прогон).
Ниже приведены усреднённые результаты десяти прогонов того же демо-приложения (старый virtual-подход против нового Ref).
UPDATE: значения некорректные. При правильной конфигурации end-to-end для старой версии оказывается быстрее на 20-30%. Остальные замеры оказываются сопоставимы.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Более синтетический тест (микробенч, ссылка на gist внизу) показывает 17.09 циклов против 14.82 циклов на один вызов, это примерно 13% экономии.
Эпилог
Концепты плюс ручные vtable позволяют добиться полиморфизма без виртуальных методов. Возможность агрегировать трейты даёт compile‑time композицию интерфейсов. Если проводить параллели, решение похоже на dyn Trait в Rust.
Кому интересно покопаться глубже, ссылки ниже.
Сам Flox живёт по этому адресу. Подключайтесь, собирайте свои системы, делитесь опытом.