Я занимаюсь разработкой С++ фреймворка для построения торговых систем. Идейно, он предоставляет строительные блоки, на основе которых можно реализовать свой сборщик маркет‑данных, торговую стратегию, систему маркет‑мейкера или любой вспомогательный сервис.
Месяц назад я выложил первую минорную версию и отправился собирать фидбэк на профильных ресурсах. Отклик оказался живым, проект оброс новыми сущностями и ближе подошёл к требованиям индустрии.
Одним из вопросов был: «почему так много 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).
|
Показатель |
Старая версия |
Новая версия |
Δ |
Δ, % |
|
публикация события |
2760 нс |
800 нс |
−1960 нс |
−71 |
|
onTrade стратегии |
960 нс |
330 нс |
−630 нс |
−65 |
|
end‑to‑end за тик |
4600 нс |
2450 нс |
−2150 нс |
−47 |
|
обработано сообщений |
140k |
167k |
+27k |
+19 |
|
L1‑D miss ratio |
3.69% |
2.72 % |
−0.97 п. п. |
−26 |
|
instr / msg |
360k |
280k |
−80k |
−22 |
Более синтетический тест (микробенч, ссылка на gist внизу) показывает 17.09 циклов против 14.82 циклов на один вызов, это примерно 13% экономии.
Эпилог
Концепты плюс ручные vtable позволяют добиться полиморфизма без виртуальных методов. Возможность агрегировать трейты даёт compile‑time композицию интерфейсов. Если проводить параллели, решение похоже на dyn Trait в Rust.
Кому интересно покопаться глубже, ссылки ниже.
Сам Flox живёт по этому адресу. Подключайтесь, собирайте свои системы, делитесь опытом.
ссылка на оригинал статьи https://habr.com/ru/articles/926282/
Добавить комментарий