Обычная системная куча обязана предполагать худшее: что в неё в любой момент постучатся из десятка параллельных потоков. Отсюда неизбежные блокировки, атомарные операции и накладные расходы. Но в GUI-приложениях на WinUI 3 этого сценария просто не бывает — все контролы и элементы разметки создаются и умирают строго в одном-единственном STA-потоке. Зачем же платить за потокобезопасность, которую мы не используем?
В этой статье мы разберем устройство sta_memory_pool — кастомного аллокатора для библиотеки wxl, работающего в 15–16 раз быстрее общей кучи. Вы узнаете, как с помощью инструкции lzcnt вычислить класс размера за один такт, как избавиться от лишних if-проверок на горячем пути с помощью самоперестраивающихся таблиц функций и как заставить линейную аллокацию работать на максимальную отзывчивость приложения при старте.
Непростой лейаут в памяти
В предыдущей статье был дан обзор общих принципов построения проекций WinRT в wxl. Каждый прикладной объект WinUI 3 проецируется по принципу pImpl.
Например, класс wxl::RadioButton имеет следующий схематичный лейаут в памяти:
RadioButton { Impl* } -> Impl { IInspectable*, IDependencyObject*, IUIElement*, IFrameworkElement*, IControl*, IContentControl*, IButtonBase*, IToggleButton*, IRadioButton* }
В конструкторе wxl::RadioButton выделяется динамическая память для wxl::RadioButton::Impl. Поля этого объекта — слоты для интерфейсов, кэшируемых в ленивой манере. Они необходимы для шустрого обращения к свойствам: ведь описание GUI для нас — это вызов конструкторов элементов, установка их свойств, подписка на события и связывание элементов в иерархию вложенных сцен.
Объекты вроде RadioButton имеют ссылочную семантику. Все копии переменной делят общий экземпляр Impl, а сам RadioButton выступает аналогом умного указателя. В WinRT все объекты тоже являются умными указателями на COM-объекты, для которых парные вызовы AddRef/Release — это виртуальные вызов «снаружи» и тяжелое Interlocked-спотыкание «внутри».
Заменив непосредственное удержание COM-интерфейса на свой инлайный и однопоточный прокси-объект refcounted, wxl сделала операции манипулирования общим состоянием дешевле. Да, мы ввели лишний уровень косвенности. Но он в любом случае необходим для обслуживания схемы ленивого кэширования интерфейсов.
Объекты-призраки
Стоит принять ту концепцию, что декларативное описание GUI в своём идиоматически чистом варианте не держит ссылок на контролы. Держаться за контролы было принято, скажем, в MFC, Windows Forms или в Qt, где контролы создавались в переменных класса формы, а оперирование ими шло в ручном режиме через имена этих переменных. Сейчас от этого можно задешево уйти через инфраструктуру двустороннего биндинга — пример можно подсмотреть в samples/Calculator.
Из этого следует парадоксальный вывод: получается, wxl создаёт объекты, которые никому не нужны? Весь граф из десятков или сотен элементов с 5–10 вызовами для установки свойств каждого (включая подписку на события) работает примерно как морской прибой — накатила волна, намочила песок и отступила, не оставив после себя ничего?
Да, именно так!
Целевое декларативное описание можно воспринимать как короткоживущую фабрику/билдер для конструирования графа визуальной сцены. Фабрика должна быстро построить строительные леса, удобным для нас образом возвести целевое здание с их помощью и так же быстро прибрать за собой.
Видится так, что без специальных приёмов работы с памятью этот сценарий не взлетит, можно даже не пытаться. За основу кастомного аллокатора для GUI была взята популярная схема пул-аллокатора по степеням двойки.
🛑 Правило, на котором всё держится
Объект sta_memory_pool объявляет жесткое правило: все обращения к нему идут строго из одного потока. Это не рекомендация, а условие существования. Списки блоков внутри пула — обычные указатели, которыми орудуют без единой атомарной операции. Конкурентные вызовы из разных потоков гарантированно разрушат структуру данных.
В wxl идентификатор потока читается напрямую из TEB (Thread Environment Block), в обход тяжеловесного std::this_thread::get_id(). При доступе в пул выполняется легковесная проверка идентификатора текущего потока через assert. Несмотря на её дешевизну, эта проверка происходит только в дебажной сборке. В релизе испаряются любые проверки. Это просто не то место в программе, которое можно обыграть по ветвлению из-за неверных входных данных.
Потокобезопасность здесь — не свойство класса, а дисциплина использования. Чужой поток гарантирует assert в отладке и UB в релизе, третьего не дано.
⚡ Класс размера за одну инструкцию или даже даром
Пул раскладывает запросы по классам-степеням двойки: от 4/8 байт до 32 КБ. Самый быстрый способ найти класс размера — через специальную функцию:
static constexpr uint32_t pool_index(uint32_t size) noexcept { return std::countl_zero(size - 1);}
std::countl_zero вычисляет число ведущих нулей. На архитектуре x86/x64 компилятор развернет этот код в инструкцию lzcnt, а на ARM64 — в нативную clz.
Посмотрим, как это работает в числах:
|
Размер |
|
Ведущих нулей |
Класс размера |
|---|---|---|---|
|
1 |
0 |
32 |
8 байт |
|
8 |
7 |
29 |
8 байт |
|
9 |
8 |
28 |
16 байт |
|
16384 |
16383 |
18 |
16 КБ |
|
32768 |
32767 |
17 |
32 КБ |
Одно вычитание и один lzcnt одновременно округляют размер запрошенного блока вверх до степени двойки и превращают этот размер в индекс. Ни ветвлений, ни делений, ни циклов.
Удивительно, но в сети полно похожих реализаций, где результат инструкции lzcnt потом еще вычитают из константы, чтобы получить прямой порядок элементов… Иногда хочется позвонить авторам такого кода и спросить — а зачем машине прямой порядок?
Массив пулов внутри нашего аллокатора расположен «в обратную сторону» — от больших размеров к меньшим, в точности как выдаёт инструкция lzcnt, исполняющаяся за один такт. Сам факт «обратности» индексов нас совершенно не беспокоит.
🗺️ Таблица функций вместо ветвлений
Полученный индекс адресует две таблицы указателей на статические функции:
using alloc_fn = void* (*)() noexcept;using free_fn = void (*)(void*) noexcept;static alloc_fn s_alloc_table[33];static free_fn s_free_table[33];
Размер массивов равен 33, так как инструкция lzcnt для 32-битного аргумента возвращает значения в диапазоне от 0 (нет ведущих нулей) до 32 (все нули).
Выделение памяти превращается в диспетчеризацию по таблице:
return s_alloc_table[pool_index(size)]();
Посчитать индекс, загрузить указатель из таблицы, вызвать функцию. В этом месте ноль дополнительных ветвлений в рантайме. Конвейер процессора отлично справляется с предвыборкой команд задолго до выполнения косвенного вызова. На разогретых кэшах L1 инструкций и данных такой вызов обходится практически бесплатно.
Из 33 доступных слотов полезной нагрузкой заняты 16 — по числу поддерживаемых классов размера (от 17-го слота для 32 КБ до 32-го для 8 байт). Самый последний, 32-й слот (соответствующий lzcnt от нуля, то есть ошибочному запросу размера 0) занят функцией обработки ошибок. Благодаря этому даже невалидный запрос своим ходом прилетает прямиком в обработчик. И снова — без лишнего if в рантайме.
Именно поэтому для вычисления индекса используется именно uint32_t, а не size_t. Нет смысла раздувать таблицы до 65 слотов на архитектуре x64, если старшая половина индексов никогда не будет задействована.
🔄 Слот, который переписывает сам себя
Простой трюк: у каждого класса размера есть две аллоцирующие функции:
-
bump_alloc<N>()— просто сдвигает курсор линейного аллокатора (bump allocator) в текущей странице памяти, возвращая указатель на зарезервированный участок памяти. Это экстремально дешево. -
pool_<N>::get()— возвращает блок из списка ранее освобожденных блоков (free list), реализуя концепцию пула свободных блоков памяти. Здесь происходит манипуляция с головой списка, возвращается последний помещённый в аллокатор блок памяти (по дисциплине LIFO). То есть выдаются самые «разогретые» в кэше участки памяти, что отлично показывает себя в бенчмарках. Это чуть дороже bump-аллокации на несколько тактов процессора, но окупается эффективностью последующего доступа к самой памяти блока.
Популярный подход в реализации таких аллокаторов совершает лишнюю проверку: «Пул свободных блоков исчерпан? Если да, двигаем курсор на странице, иначе берем из списка». Но любая проверка — это ветвление, а регулярные ветвления на горячем пути аллокации неизбежно приводят к промахам предсказателя переходов процессора. Самое досадное, что тут бесполезно расставлять атрибуты [[likely]] и [[unlikely]]: мы не можем знать заранее мгновенную ситуацию в нашем пул-аллокаторе.
А что если сделать алгоритм самоадаптирующимся, чтобы тот «знал» наиболее вероятную ветку ветвления заранее? Сказано — сделано. В wxl ответ хранится в самом слоте таблицы, который меняет свой указатель динамически.
Итак, у нас есть две аллоцирующие функции — bump_alloc<N> и pool_<N>::get, а также функция возврата блока памяти в пул pool_<N>::put.
Весь алгоритм целиком:
-
По умолчанию в слоте выставлен адрес функции
bump_alloc<N>. Пока память только выделяется, она идет через дешевое скольжение курсора. -
Вызов
pool_<N>::putвозвращает блок памяти в список свободных блоков и переключает адрес аллоцирующей функции в таблицеs_alloc_table[index]наpool_<N>::get, чтобы при следующем обращении отдать этот же блок. -
Когда свободные блоки из пула полностью заканчиваются, то
pool_<N>::getпереключает адрес в таблицеs_alloc_table[index]обратно наbump_alloc<N>.
template <unsigned N>struct sta_memory_pool::pool_ { inline static constexpr unsigned index = pool_index(1u << N); static void* get() noexcept { if (void* node = top_) [[likely]] return (top_ = *static_cast<void**>(node)), node; s_alloc_table[index] = bump_alloc<N>; // Список опустел — слот возвращается к курсору return bump_alloc<N>(); } static void put(void* node) noexcept { (*static_cast<void**>(node) = top_), top_ = node; s_alloc_table[index] = &pool_::get; // Появился свободный блок — слот идет к списку } static void* top_;};
Основное тело bump_alloc<N>:
if (byte* c = s_cursor, *c1 = c + size; c1 <= s_end) [[likely]] return (s_cursor = c1), c;
Итог: вопрос «а есть ли что-то в пуле свободных блоков?» задается тогда, когда там с большой вероятностью что-то есть. Всё остальное время ответ закодирован в самой таблице функций. На практике вероятность верного предсказанного ветвления оказалась большой — этому подыгрывает групповая природа операций в нашем сценарии, когда выделения и уничтожения редко идут по одному блоку друг за другом, а накатывают и откатываются обратно большими волнами.
📈 Битва бенчмарков или «сказано — сделано»
Такая схема была выбрана не за красоту, а потому что она единственная выжила на поле боя бенчмарков. Тестировались разные подходы: от классических проверок списка до архитектуры вообще без таблиц, где функции вызывались напрямую, а классы размеров выражались через CRTP и наследование специализаций. Мы доходили вплоть до хардкода пулов под конкретные прикладные типы, которые ими обслуживаются. Всего были тщательнейшим образом протестированы 8 разновидностей однопоточного аллокатора с минимальными отличиями вдоль градиента вариаций подходов — чтобы случайно не пропустить «экстремум».
Каждое сочетание прогонялось на сценариях горячего пути выделения (sta_allocator_get_benchmark.cpp) и на «настоящей» нагрузке — дереве вложенных контейнеров, которое строится, перетряхивается и рушится (sta_allocator_scenario_benchmark.cpp).
На сценариях, где освобождения чередуются с выделениями (balanced), ведущие 4 вариации оказались практически неразличимы. На масштабе единиц наносекунд то, как компилятор выровнял код в памяти, значит больше, чем то, что этот код фактически делает.
А вот сценарий fresh (сплошные выделения и ни одного освобождения) разделил кандидатов сразу и с заметным отрывом. В нём решается единственный вопрос: сколько стоит выделение, когда список свободных блоков пуст. У самоперестраивающихся таблиц ответ очевиден — ноль сверх самого выделения. Как только список свободных блоков пула опустел, аллокатор переключается на функцию bump_alloc<N>. После этого каждое следующее выделение попадает в скользящий курсор.
И это ровно тот профиль нагрузки, в котором живет запуск любого GUI-приложения. Построение дерева интерфейса при старте — это тысячи аллокаций подряд (чтение конфигурации, элементы управления, коллекции, строковые операции) и редкие освобождения. Массовое выделение памяти при инициализации GUI — главный сценарий для этого аллокатора, и механика автоматического переключения аллокатора на безусловный путь bump-курсора окупается с запасом.
Текущие показатели пула (Release, класс размера 32 байта, время в наносекундах):
|
Сценарий |
Что делает |
Время (нс) |
|---|---|---|
|
|
Снятие блока с непустого списка, без дилемм |
2.30 |
|
|
Освобождение + выделение |
2.45 |
|
|
Только выделения, свободный список пуст |
1.95 |
Продвинуть курсор линейного аллокатора (fresh) дешевле, чем снять узел со списка и прочитать из него указатель на следующий. Выбранная схема подыгрывает отзывчивости приложения на старте, за это она и была назначена победителем.
На самом деле, почти любой из однопоточных пул-аллокаторов даёт резкий буст производительности, то есть позволяет схеме проецирования WinRT в wxl в принципе существовать в выбранном варианте. Но раз уж вышло так, что вся схема критично зависит от аллокатора, то почему бы не довести вопрос до абсурда идеала? Сказано — сделано.
🗄️ Откуда берётся память и где пул заканчивается
Память запрашивается страницами через VirtualAlloc. Аллокатор берёт у системы крупный кусок — не меньше 256 КБ (по умолчанию — один мегабайт, размер страницы настраивается однократно при инициализации пула). Когда текущая страница заканчивается, аллокатор выделяет следующую. Хвост старой страницы не пропадает зря — он рубится на максимальные выровненные блоки-степени двойки и раздаётся по свободным спискам обычным вызовом pool_<N>::put.
Пул идеален для мелких и многочисленных объектов, но для крупных аллокаций он вреден, ведь эта машинерия удерживает память до конца работы программы. Пул хорош тогда, когда непрерывно совершает работу: выдаёт блоки, получает их обратно и снова выдаёт. И плох, если он однажды выделил большой кусок, забрал его назад и больше никому не отдаёт.
Именно поэтому для специфического сценария парсинга XML в читалке книг «Буквица» реализован брат-близнец описанного аллокатора. Он оперирует исключительно блоками по 64 КБ (то есть там нет таблицы слотов, а есть единственный слот). Такова узкая специфика: типичные размеры книг попадают в диапазон от сотни килобайт до мегабайта, а сам XML-документ имеет «кучерявую» структуру. В этой ситуации лучше всего подошла концепция «арены», где вся память освобождается разом без вызовов деструкторов у объектов. Да, вы правильно поняли: решение по месту порой лучше даже самого крутого общего решения (особенно если это частное решение взяло у общего всё самое лучшее).
Из этих соображений запросы памяти к sta_memory_pool разбиты на три уровня:
-
До 32 КБ: Свободные списки свободных блоков поверх страниц — то, ради чего всё и задумывалось.
-
От 32 КБ до половины страницы: Приватная Windows-куча STA-потока. Она обслуживается компонентом
thread_heapнад системным Win32 API куч, инициализированным с флагомHEAP_NO_SERIALIZE. Так мы просим операционную систему полностью отключить внутренние блокировки кучи за ненадобностью. -
Больше половины страницы: Обычный
std::mallocи общая куча процесса. После возврата эта память может быть переиспользована другими потоками приложения.
Таким образом, использовать sta_memory_pool для выделения больших блоков памяти будет означать лишь удлинение цепочки аллокации, потому что он не для этого сценария.
⚙️ Когда размер известен компилятору
Благодаря инлайнингу до честного выполнения инструкции lzcnt в рантайме дело доходит редко. При вызове new T выражение sizeof(T) является константой времени компиляции, и от всей адресации в релизной сборке остается лаконичное:
s_alloc_table[28](); // Адрес таблицы и индекс известны на этапе компиляции
Но если хочется быть окончательно уверенным — у функций alloc и free есть NTTP-шаблонные двойники (Non-Type Template Parameters), принимающие размер в качестве параметра шаблона:
template <uint32_t Size>static void* alloc() noexcept;
🚀 Как этим пользоваться
Пул — статическая сущность, его код перемещён прагмой в сегмент lib, т.е. будет инициализирован раньше сегмента user, где живёт код целевой программы, и уничтожен после уничтожения последних глобальных объектов программы.
Помимо переопределения операторов new/delete, STA-пул подхватывается через sta_allocator<T> — обычный std::allocator-совместимый переходник:
template <typename T>using sta_allocator = allocator_adapter<T, sta_memory_pool>;
Благодаря этому wxl::vector, wxl::wstring и прочие компоненты — это всё те же стандартные std-контейнеры, у которых просто подменён бэкенд. STL остается привычной STL, но память под ней работает совершенно иначе.
📌 Что стоит помнить
-
Один поток — нарушение этого правила гарантирует неопределенное поведение (
UB). -
Удержание памяти — пул не возвращает страницы операционной системе до момента собственной смерти, что является осознанным компромиссом ради аллокации ценой в три процессорные инструкции.
Где еще подменяются слоты функций?
В конструкторах объектов WinUI 3.
Вот последовательность шагов конструирования обычной кнопки стандартными средствами WinRT:
-
Вызов
RoGetActivationFactory("winrt::очень_длинный_неймспейс::Button")— получаем указатель на фабрику; -
Вызов
ActivateInstanceу фабрики — получаем базовый объектIInspectable; -
Запрос целевого интерфейса
IButtonу объектаIInspectableчерезQueryInterface. Полученный экземпляр запрошенного интерфейса присваивается умному указателю, а полученному на предыдущем этапе экземпляруIInspectableвызываетсяReleaseкак сигнализация об окончании владения.
Видно, что получение экземпляра фабрики в этой цепочке обходится дорого. Первое естественное желание — закэшировать экземпляр фабрики при первом же обращении. Именно так и происходит в WinRT. Но делается это в расчёте на общий случай доступа из множества параллельных потоков. Кэширование в проекции cppwinrt реализовано через атомарные операции interlocked cas, ведь несколько потоков могут конкурентно пытаться сохранить фабрику для одного и того же объекта.
Но для GUI-объектов этот сценарий невозможен! Мы снова платим за потокобезопасность, которой не пользуемся.
Далее, сам факт того, закэширована ли фабрика, нужно постоянно проверять с помощью ветвления. Но и это не конец неприятностей. В WinRT фабрика является шаблонной и инлайновой. В каждом месте создания кнопки, даже если ветвление всегда идёт по горячему пути (где фабрика уже существует), в каждом методе компилятор генерирует else-ветку. Она запрашивает фабрику по имени, а затем устраивает CAS-гонки с потенциальными конкурирующими потоками за право установить свой экземпляр в качестве глобального.
Итого, нетривиальный код алгоритма кэширования встраивается компилятором в каждый метод, где создаётся объект WinRT. Если в методе создаётся десяток контролов — значит, внутри него будет жить столько же раздутых вариантов инициализации фабрик.
Можем ли мы спокойно смотреть на этот беспредел? Внутренний перфекционист отвечает однозначное «Нет!».
Как это выглядит в wxl
-
Никаких ветвлений — слот кэша фабрики не может быть пуст. Его не нужно проверять через
if, вызов происходит сразу. -
Функция-заглушка на старте — по умолчанию в этом слоте лежит адрес stub-функции
init. Она лениво инициализирует фабрику при первом обращении и просто подменяет собственный слот на адрес целевой функции.
«Но ведь фабрика — это COM-объект, чьи методы обязаны вызываться как виртуальные!» — поправите вы меня и будете абсолютно правы. Это именно COM-объект, использующий соглашение о вызовах __stdcall. Данное соглашение на бинарном уровне не различает this-вызовы и вызовы обычных статических функций. Иначе как бы эти интерфейсы вызывались из чистого Си или других языков в интеропе с ними?
Поэтому под слот выделено две ячейки: для самого экземпляра фабрики и для непосредственного адреса функции из 6-го слота интерфейса IActivationFactory. Конструктор WinUI 3-объекта теперь схематично выглядит так (аргумент this передаётся первым):
static void activate(::IInspectable** result) { slot.fn(slot.fac, result);}
Виртуальный вызов заменяется на вызов функции по указателю. Это экономит не просто такты, а разрушает длинную цепочку зависимостей по данным (Data Dependency Chain) и избавляет от рантайм-проверок.
В стандартном WinRT процессору на горячем пути приходится сначала прочитать адрес фабрики из статического кэша, проверить его через if на nullptr, затем по полученному адресу прочитать указатель на vtable объекта и только после этого вычислить адрес целевой функции. Каждое следующее чтение физически ждет завершения предыдущего.
В нашем же случае адреса slot.fn и slot.fac лежат рядом в локальной структуре. Процессор загружает их в регистры одновременно и параллельно, минуя и проверку на nullptr, и обращение к vtable фабрики, сокращая время вот такого целевого вызова:
struct IActivationFactory : IInspectable { virtual HRESULT __stdcall ActivateInstance(IInspectable** instance) = 0;};
Почему слот именно шестой и почему он не может измениться в будущем?
Таков строгий ABI-контракт COM от самого его рождения: когда требуется дополнить или изменить функциональность, разработчикам разрешено только выпускать новые интерфейсы. Изменять однажды опубликованные интерфейсы запрещено. Именно поэтому мы можем позволить себе навсегда захардкодить знание о том, какую по порядковому номеру функцию нужно взять у фабрики и поместить в наш слот (три метода IUnknown + три метода IInspectable— метод ActivateInstance занимает шестую позицию в vtable).
Итого, в момент конструирования WinUI 3-объекта средствами wxl происходит простой вызов, как показано в листинге выше. И… это всё?
Нет, внимательный читатель уже заметил, что стандартный WinRT после создания объекта сразу же запрашивает дефолтный интерфейс класса. Ведь семантически в коде был вызван конструктор объекта, пусть даже под капотом это тяжеловесный COM. В нашем же случае у Button::Impl инициализируется только базовый указатель на IInspectable, полученный от фабрики единственным вызовом, т.е. дефолтный интерфейс на старте не запрашивается. Он будет получен лениво и закэширован в будущем — точно так же, как и любой другой интерфейс из десятка доступных для простой кнопки.
Но вот что забавно: именно дефолтный интерфейс IButton в реальности запрашивается используется крайне редко. Почему?
-
Размеры и стили — это механика базовых интерфейсов
IUIElementиIFrameworkElement. -
Событие
Click— зона ответственности базовогоIControl. -
Текст, иконки или сложные сцены внутри — это присвоение свойству
ContentбазовогоIContentControl. -
Связывание с родителем — когда наступает пора отдать контрол в качестве дочернего элемента, кнопка запрашивает у себя интерфейс
IUIElement, который с большой вероятностью уже был закэширован на этапе настройки свойств.
Давайте заглянем в winmd-файл метаданных и посмотрим, что на самом деле скрывает в себе интерфейс IButton:
internal interface IButton{ FlyoutBase Flyout { get; [param: In] set; }}
Свойство Flyout не используется практически никогда. Из-за этого в реальных приложениях ваши объекты wxl::Button при создании класса вообще никогда не будут запрашивать дефолтный интерфейс самой кнопки! Как бы парадоксально это ни звучало для экосистемы COM.
Благодаря ленивому кэшированию интерфейсов с одновременным удержанием их внутри прокси-объекта, библиотека wxl избегает лишнего дорогого первоначального вызова QueryInterface. Этот вызов по умолчанию совершают все без исключения программы на C++, написанные под современный WinRT, даже если дефолтный интерфейс им никогда не понадобится. Но ещё больше схема wxl экономит ресурсы при последующем активном манипулировании свойствами сложных UI-компонентов со множеством базовых интерфейсов.
Когда не нужно боксирование
Простой пример: WinRT передаёт свойствам аналог std::optional<bool> через IReference<bool>. При этом IReference<T> — точно такой же тяжеловесный COM-объект, создающийся с соблюдением всех церемоний. И вся эта тяжеловесная механика нужна только для того, чтобы выставить свойство ToggleButton::IsChecked, которое может принимать три состояния: true, false или «не определено».
Здесь мы отбросили даже наши STA-трюки. Эксперименты показали, что WinRT никогда не захватывает владение переданными ему в качестве аргументов объектами-значениями. Исключение составляют лишь расшаренные иммутабельные строки hstring, у которых просто инкрементируется счётчик ссылок.
Динамически конструировать такой объект из STA-пула — идея рабочая, но не идеальная. Мы пошли дальше: расположили два статических экземпляра по значению прямо в глобальной памяти — winrt_true и winrt_false. Метод установки свойств просто отправляет адреса этих экземпляров. Самое приятное, что эти адреса известны ещё на этапе компиляции. Методы AddRef/Release у данных объектов пусты (да и на практике их никто не вызывает), полей они не имеют, т.е. могут безопасно располагаться по значению в глобальной памяти. И даже если WinRT самым честным образом однажды захватит владение таким объектом через AddRef — всё продолжит работать как задумано.
Да, для других типов данных приходится заворачивать их боксированное представление во временные объекты из STA-пула. Но таких сценариев исчезающе мало (хоть они и полностью покрыты в wxl). Впрочем, именно для таких редких ситуаций мы и проектировали наш STA-пул — чтобы не испытывать досаду всякий раз, когда требуется временно выделить динамическую память только для того, чтобы тут же её освободить.
Оно того стоило?
Проверить жизнеспособность подхода поможет простой тест: создание и настройка сцены примерно из сотни элементов. Одинаковая сцена строится средствами голого WinRT и средствами wxl. Результаты бенчмарка приведены в таблице.
|
Тест |
wxl |
cppwinrt руками |
|---|---|---|
|
Постройка сцены, лучшая |
2.39–2.48 мс |
2.49–2.51 мс |
|
Проход записей, на запись |
1626–1681 нс |
1690–1702 нс |
Это несколько порвало шаблон самому автору, ведь изначальная ретроспектива выглядела примерно так:
-
Ожидание: «За декларативность GUI и удобные хелперы наверняка придётся заплатить какую-то цену… Надеюсь, что она не окажется слишком высокой».
-
Реальность: Декларативность и
pImpl-проекция вышли бесплатными. Они работают не медленнее ручного cppwinrt, а даже немного быстрее.
Итак. Хелперы wxl выполняют дополнительный код. Проекция создает динамические pImpl-структуры только для того, чтобы сразу же выкинуть их за ненадобностью после завершения сборки GUI. Пресеты тоже образуют динамически выделяемые объекты-сеттеры, призванные дать удобный механизм декомпозиции описания UI средствами языка C++. Мы однозначно делаем больше на верхнем уровне, чтобы позволить программисту писать меньше и в лаконичном синтаксисе. И в то же самое время мы делаем заметно меньше на самом низком уровне, что и позволило свести баланс в плюс.
Оно того стоило, и результаты продиктовали однозначный вердикт: wxl быть.
Немного анализа. Относительный перевес wxl при построении сцены вышел небольшим — всего 3–4 %.
Почему? Стоит знать две важные цифры:
-
Разница на одну запись свойства — около 40 нс. Это ровно тот
QueryInterface, который cppwinrt делает по умолчанию, а wxl — нет. -
Сама запись свойства в WinUI 3 стоит около 1.7 мкс.
Стоимость, казалось бы, тривиальной операции записи значения оказалась близка стоимости двух вызовов функций ядра (kernel transition) на моей машине. А ведь мьютекс этого не делает, если на нём не случилось столкновения потоков, то есть дело не только в потенциальной сериализации доступа к графу GUI. Благо исходники WinUI 3 открыты, и однажды можно будет провести детальное исследование ради удовлетворения любопытства: что именно там так тяжело ворочается? Не хочется забегать вперёд, но пока хочется осторожно верить: если решение будет найдено, оно сможет однажды попасть в саму WinUI 3.
Спойлер: в следующей статье мы заглянем в святая святых современного C++ — в корутины и асинхронщину. Поговорим о том, насколько теперь удобно обслуживать классически нелинейную событийную модель UI через уютно-линейный асинхронный код (мне до сих пор словосочетание «линейный асинхронный код» режет слух и кажется оксюмороном… но таков актуальный сленг) и прочее зуммерское ми-ми-ми, которое становится доступным, стоит лишь организовать всё по уму.
Также мы разберем, каким образом наш «хрустальный» STA-пул запросто обслуживает потребление памяти в произвольных I/O-потоках и делает это с присущей ему эффективностью, пугая топовые ИИ кульбитами происходящего. Да так, что те отказываются верить своим глазам, упорно ищут ошибки в многопоточной схеме с однопоточным аллокатором, спорят на повышенных тонах (о да, они уже научились и этому!), а когда до них наконец «доходит» — радуются как маленькие дети.
В области корутин получилось не просто выйти на нулевую стоимость в сравнении с cppwinrt, но и куда заметнее убежать вперёд. Одним словом, продолжаем превращать ограничения сценариев в ультимативные бонусы.
ссылка на оригинал статьи https://habr.com/ru/articles/1082702/