Files
CFDManager/docs/rust_vs_cpp.md
T
NotBigGhostandClaude Opus 5 1d7f9191ea Сравнение Rust- и C++-версий: замеры и разбор расхождений
Условия, цифры и разбор по коду. Замеры на одной машине подряд: сборка (полная,
тёплая, инкрементальная — в release и debug), размеры бинарей и каталогов, состав
и объём зависимостей, старт до готовности рендерера, процессорное время при 60 Гц,
строки по модулям одинаковыми правилами для обеих версий.

Главные наблюдения:
 * инкрементальная release-сборка Rust 52 с против 4.4 с — это цена thin LTO, а не
   языка: со сравнимым уровнем оптимизации 5.5 с против 4.4 с;
 * FetchContent клонирует 775 МБ истории на каждый каталог сборки и не имеет общего
   кэша; 76 крейтов Rust занимают 80 МБ в общем реестре;
 * загрузчики .obj сходятся на cow.obj точно и расходятся на plane.obj на 0.13%
   треугольников — из-за разной триангуляции многоугольников с n >= 5.

Плюс четыре дефекта C++-версии, найденных при переносе, в том числе строка
«Mesh ready», которую троттлинг категории MeshIO отбрасывает всегда: счётчики после
сварки не печатались никогда.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 20:58:33 +03:00

396 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Rust против C++: две версии редактора SimVulcan
Ветка `rust` содержит порт редактора с C++20 на Rust. Обе версии лежат в дереве рядом,
собираются независимо и делают одно и то же. Этот документ — не выбор победителя, а
разложенные факты: что изменилось, чего это стоило и что померилось.
## Что именно сравнивается
Программа одна и та же: окно 1280×720, орбитальная камера вокруг начала координат, три
опорные плоскости сетки и цветные оси XYZ, загрузка `.obj` в отдельном потоке, сварка
вершин с допуском `1e-4`, три режима отображения модели, единый проход динамического
рендеринга с глубинным вложением, submit через `synchronization2`, два кадра в полёте.
**Общее:** каталог `shaders/` — обе версии компилируют одни и те же `.vert`/`.frag` тем
же `glslangValidator -V --target-env vulkan1.3`; каталог `assets/` — одни и те же модели.
**Разное по договорённости:** интерфейс. В C++ это Dear ImGui, в Rust — **egui**. Крейт
`imgui` существует, но это биндинги: сборка всё равно тянула бы ~40 тыс. строк C++ через
`cc`, и «Rust-версия», компилирующая C++, обесценила бы сравнение экосистем. Панели
из-за этого выглядят иначе; набор элементов управления тот же.
## Условия замеров
| | |
|---|---|
| машина | Intel Core i5-1135G7, 4 ядра / 8 потоков, 16 ГБ, SSD |
| видео | Intel Iris Xe, драйвер 31.0.101.5186, экран 60 Гц |
| ОС | Windows 11 Home 10.0.26200 |
| C++ | MSVC 14.50.35717 (VS 18 BuildTools), CMake 4.3.2, Ninja 1.13.2 |
| Rust | rustc / cargo 1.97.1, MSVC-ABI |
| общее | Vulkan SDK 1.4.341.1 |
Обе сборки — релизные (`CMAKE_BUILD_TYPE=Release`; `cargo --release` с `lto = "thin"`,
`codegen-units = 1`). Обе однопоточно ограничены одним и тем же железом; замеры сделаны
подряд, без других нагрузок.
## Цифры
### Сборка
Все числа C++ уменьшены на 3.0 с — накладные расходы `vcvars64.bat`, который приходится
вызывать в каждом сеансе (замерены отдельно, среднее из трёх). У `cargo` такого нет.
| | C++ | Rust |
|---|---|---|
| конфигурация (исходники зависимостей на месте) | 9.7 с | входит в сборку |
| **release с нуля** | **114 с** | **194 с** (thin LTO) · 182 с (без LTO) |
| release, ничего не менялось | 1.1 с | 1.0 с |
| **release после правки одного файла** | **4.4 с** | **52.3 с** (thin LTO) · 5.5 с (без LTO) |
| debug с нуля | 112 с | 83 с |
| debug после правки одного файла | 8.0 с | 5.2 с |
Правился в обоих случаях самый крупный файл Vulkan-слоя: `src/vk/Renderer.cpp` и
`crates/simv-vk/src/renderer.rs`.
Две оговорки, без которых таблица врёт.
**Уровни оптимизации разные.** `CMAKE_BUILD_TYPE=Release` у MSVC — это `/O2` без
оптимизации всей программы (CMake не включает `INTERPROCEDURAL_OPTIMIZATION` сам).
Профиль порта просит `lto = "thin"` и `codegen-units = 1`, то есть межкрейтовую
оптимизацию. Отсюда и 52 секунды на инкрементальную пересборку: правка одной строки
заставляет заново оптимизировать и слинковать весь бинарь. Колонка «без LTO» —
`CARGO_PROFILE_RELEASE_LTO=false`, `CODEGEN_UNITS=16`, то есть примерный аналог `/O2`
без LTCG: **5.5 с против 4.4 с**, и разницы уже нет. Инкрементальная сборка в Rust
медленная не сама по себе, а ровно настолько, насколько её просят оптимизировать.
**«С нуля» здесь не значит «с чистой машины».** Обе цифры сняты при уже скачанных
зависимостях. Честная холодная сборка C++ до конца не дошла: `FetchContent` клонирует
девять репозиториев с полной историей, и на этом соединении она за пятнадцать минут
успела скачать два из них (glfw 24 МБ, glm 100 МБ) и была прервана. Готовое дерево
зависимостей от отладочной сборки занимает **775 МБ**, и общего кэша у FetchContent нет:
каждый новый каталог сборки скачивает всё заново. Реестр cargo, наоборот, один на
пользователя.
### Артефакты и зависимости
| | C++ | Rust |
|---|---|---|
| `SimVulcan.exe`, release | **1.18 МБ** | **5.80 МБ** |
| `SimVulcan.exe`, debug | 5.68 МБ | 28.97 МБ |
| каталог сборки | 78 МБ | 469 МБ |
| объявлено зависимостей | 9 | 15 |
| всего пакетов в графе | 10 | **76** |
| исходники зависимостей на диске | **775 МБ** на каждый каталог сборки | **80 МБ** в общем реестре |
Пятикратная разница в размере бинаря — это в основном статически влинкованная стандартная
библиотека Rust и форматирование `core::fmt`; C++-версия тянет CRT из системы. Разница в
числе пакетов (76 против 10) впечатляет ровно до того момента, как посмотреть на объём:
76 крейтов Rust занимают в восемь раз меньше места, чем девять библиотек C++, потому что
`.crate` — это архив выпуска, а `FetchContent` — клон с историей.
### Выполнение
Обе версии показывают в режиме FIFO на экране 60 Гц, поэтому частота кадров у них
одинаковая по построению и ничего не измеряет. Сравнимы старт и процессорная стоимость.
| | C++ | Rust |
|---|---|---|
| старт до готовности рендерера (лучший из трёх) | 1068 мс | **754 мс** |
| то же, средний | 1080 мс | 872 мс* |
| процессорное время при 60 Гц | **11.2%** реального | 12.6% реального |
\* без первого запуска сразу после сборки: он занял 3.4 с, пока система подтягивала
свежий 5.8-мегабайтный образ с диска. У C++-версии разброс между запусками — 21 мс.
Полтора процента разницы по процессору — это egui против Dear ImGui, а не Rust против
C++: egui пересобирает раскладку каждый кадр, ImGui хранит её между кадрами. Сцена
(сетка + модель) в обеих версиях записывается одинаковыми вызовами.
### Совпадают ли результаты
Проверка, что порт считает то же самое: один и тот же файл загружается обеими версиями,
сравниваются счётчики.
| файл | C++ (tinyobjloader) | Rust (tobj) |
|---|---|---|
| `cow.obj` | 2451 вершины, 4898 треугольников | **2451, 4898** |
| `plane.obj` | 10673 вершины, 20035 треугольников | 10638, **20061** |
`cow.obj` сходится точно. `plane.obj` расходится, и оба расхождения объяснимы:
* **35 вершин.** tinyobjloader отдаёт глобальный массив `attrib.vertices` целиком, включая
вершины, на которые не ссылается ни одна грань; tobj такие отбрасывает. Сварка потом
всё равно оставила бы их висеть в списке — на картинку они не влияют.
* **26 треугольников.** В `plane.obj` 121 грань с пятью и более вершинами. tobj режет
n-угольник веером, что даёт ровно `n − 2` треугольника — суммарно 20061, и это
совпадает с прямым подсчётом по файлу. tinyobjloader для `n ≥ 5` применяет
earcut-подобную триангуляцию и выбрасывает выродившиеся треугольники, отсюда 20035.
Разница 0.13% и целиком лежит в библиотеке чтения, а не в переносе.
Сварка вершин (`weld_vertices`) перенесена построчно, включая бакетирование округлением
и FNV-хеш; три её теста из Catch2 перенесены дословно и проходят.
### Побочная находка: строка, которой не бывает
При сверке счётчиков выяснилось, что C++-версия **никогда не печатает** результат сварки.
`ObjLoader::LoadObj` пишет `Loaded OBJ '…': N verts, M tris` в категорию `MeshIO`, а
обработчик в `main.cpp` сразу за ним пишет туда же `Mesh ready: …`. У категории стоит
троттлинг 0.5 с, оба сообщения уровня `Info` — второе гарантированно отбрасывается.
В журналах обеих загрузок (`cow.obj` и `plane.obj`) строки `Mesh ready` нет.
Это и стало причиной единственного расхождения в переносе журнала: в порте троттлинг по
умолчанию выключен, а `set_throttle` включается там, где сообщения идут покадрово.
Иначе стартовый вывод терял бы имя видеокарты — в C++ он уцелел лишь потому, что
низкоуровневый код Vulkan пишет напрямую через `spdlog`, минуя категории.
## Строки кода
Считано одинаковыми правилами для обеих версий: строка комментарийная, если после
обрезки пробелов начинается с `//` (для C++ ещё блочные `/* … */`); хвостовые
комментарии за кодом не вычитаются.
| модуль | C++ | | | | Rust | | | |
|---|---|---|---|---|---|---|---|---|
| | всего | пусто | комм. | **кода** | всего | пусто | комм. | **кода** |
| журнал, окно (`core`) | 370 | 83 | 11 | **276** | 310 | 36 | 62 | **212** |
| Vulkan (`vk`) | 2120 | 380 | 88 | **1652** | 2491 | 238 | 255 | **1998** |
| меш (`mesh`) | 236 | 50 | 14 | **172** | 395 | 50 | 70 | **275** |
| редактор (`editor`) | 305 | 68 | 20 | **217** | 431 | 48 | 62 | **321** |
| приложение (`app`) | 85 | 14 | 5 | **66** | 226 | 27 | 43 | **156** |
| тесты | 65 | 9 | 5 | **51** | *внутри модулей* | | | *≈150* |
| **итого** | **3181** | 604 | 143 | **2434** | **3853** | 399 | 492 | **2962** |
| система сборки | 431 | | | **399** | 232 | | | **197** |
Порт длиннее примерно на пятую часть, и перевес почти весь в Vulkan-слое: +346 строк кода
в `simv-vk` — это написанная руками замена vk-bootstrap. Приложение выросло с 66 до 156
строк, потому что в него переехал `App` из `simv_core` (см. расхождение 1) и поиск
каталога моделей стал честным обходом предков вместо пяти захардкоженных `../`.
Комментариев в порте втрое больше (492 против 143), и это перекос замера, а не свойство
языка: значительная их часть — пометки «здесь расхождение с C++-версией и вот почему»,
написанные ради этого документа. По строкам собственно кода разрыв — 2962 против 2434,
и он меньше, если вычесть тесты: они в Rust живут внутри модулей (≈150 строк), в C++ —
отдельной целью (51 строка).
## Структурные расхождения
Каждое — с причиной. Ни одно не сделано «чтобы было красивее».
### 1. `App` переехал в исполняемый крейт
В C++ граф целей цикличен: `core::App` владеет `vk::Renderer`, а `vk::Renderer`
принимает `core::Window&`. Работает это только потому, что `simv_vk` **не линкует**
`simv_core`, а видит его заголовки через `target_include_directories(simv_vk PUBLIC ..)`,
и всё сходится на компоновке исполняемого файла. Cargo цикл между крейтами отвергает
сразу, поэтому `App` (25 строк) поднят на уровень выше обоих — в `simv-app`.
Это единственный пункт, где Rust потребовал перекладывать код, и заодно единственный,
где он указал на настоящую проблему в исходной раскладке: срез слоёв, который CMake
пропустил молча.
### 2. Границу модулей проверяет компилятор
Главный инвариант проекта — «весь Vulkan живёт в `src/vk/`» — в C++ держится на
дисциплине и записи в `CLAUDE.md`: ничто не мешает написать `#include <volk.h>` в
`mesh/ObjLoader.cpp`, все заголовки видны. В порте `simv-mesh` и `simv-editor` просто не
имеют `ash` в зависимостях: нарушение не соберётся.
Побочный эффект: `EditorUI` не может назвать тип `vk::RenderMode`. C++ обходит ту же
границу, возвращая из панели голый `int`, который `main.cpp` приводит
`static_cast<vk::RenderMode>` — связь двух перечислений там держится на честном слове.
В порте у панели свой `DisplayMode`, а разбор делает `match` в `simv-app`: добавится
вариант — компилятор укажет место.
### 3. pImpl не понадобился
`vk::Renderer` в C++ прячет всё за `struct Renderer::Impl` и `unique_ptr` ради одного:
чтобы заголовок не тащил volk в трансляционные единицы `App` и `main`. В Rust заголовков
нет, и приватные поля модуля дают ту же изоляцию бесплатно — минус один уровень
косвенности и минус ручное объявление/определение `Impl` на 45 строк.
### 4. Замыкание интерфейса стало параметром, а состояние — возвратом
В C++ `Renderer` хранит `std::function<void()>`, а замыкание из `main.cpp` захватывает по
ссылке камеру, панели и **сам рендерер**, чтобы вызвать `SetViewProj`, `SetRenderMode`,
`SetGridVisible`. В Rust так нельзя: замыкание вызывается из метода рендерера, то есть
рендерер оказался бы одолжен дважды.
Решение: замыкание передаётся параметром в `draw_frame` и **возвращает** `FrameState`
с матрицей вида-проекции, режимом и видимостью сетки. Состояние применяется к тому же
кадру, отставания нет. Сеттеры исчезли за ненадобностью.
```cpp
// C++: состояние ставится изнутри колбэка
app.SetUiCallback([&]() {
editorUI.Draw(camera);
renderer.SetViewProj(camera.ViewProj(renderer.AspectRatio()));
});
```
```rust
// Rust: состояние возвращается из замыкания
renderer.draw_frame(window, |ctx| {
editor_ui.draw(ctx, camera);
FrameState { view_proj: camera.view_proj(aspect), .. }
})?;
```
### 5. Выгрузка меша уехала из середины кадра
В C++ панель загрузки зовёт `OnLoaded` прямо из `Draw`, то есть из середины
`Renderer::DrawFrame`, **уже после** `vkAcquireNextImageKHR`. Обработчик оттуда дёргает
`SetMeshCpu`, а тот — `vkDeviceWaitIdle` и выгрузку на видеокарту. Законно, но ждать
простоя устройства посреди записи кадра — сомнительно.
В Rust это невозможно по той же причине, что и пункт 4. Панель складывает готовый меш в
поле, приложение забирает его после кадра. Ограничение языка здесь вынесло тяжёлую
операцию из горячего пути — не потому, что кто-то это заметил.
### 6. `Buffer` и `Image` перестали быть мёртвым грузом
В C++ обе обёртки собираются в цель `simv_vk`, но **ими не пользуется никто**: и
`GridRenderer`, и `GpuMesh`, и глубинное вложение `Renderer` создают ресурсы прямыми
вызовами VMA. Это 200 строк, которые компилируются каждую сборку и никогда не работают.
В порте на них построены все три потребителя — не из-за языка, а потому что порт писался,
когда потребители уже известны. С gpu-allocator обёртка ещё и обязательна по существу:
она держит `Allocation`, который иначе пришлось бы освобождать руками в каждом `Drop`.
### 7. Асинхронная загрузка: `mpsc` вместо `std::future`
`LoadObjAsync` в C++ возвращает `std::future<Mesh>`, и исключение из рабочего потока
всплывает при `get()`. В порте поток шлёт `Result<Mesh, ObjError>` по каналу, панель
опрашивает `try_recv()`. Разница не в количестве кода (его столько же), а в том, что
ошибка стала значением: пропустить её нельзя, а тип в сигнатуре перечисляет, что вообще
может пойти не так.
### 8. SPIR-V вшит в исполняемый файл
`build.rs` компилирует шейдеры в `OUT_DIR`, откуда они попадают в бинарь через
`include_bytes!`. Вместе с этим исчезли: функция `FindSpvPath` с перебором путей, два
POST_BUILD-шага копирования каталогов в `CMakeLists.txt`, требование запускать редактор
из его собственного каталога и целый класс ошибок «шейдер не найден» во время выполнения
— отсутствующий шейдер теперь ломает сборку. Из путевых зависимостей остался только
`assets/meshes`, и он ищется от бинаря, а не от текущего каталога.
### 9. Цикл событий принадлежит библиотеке
GLFW отдаёт цикл приложению (`while (!ShouldClose()) { PollEvents(); DrawFrame(); }`),
winit забирает его себе и зовёт приложение через `ApplicationHandler`. `Window::ShouldClose`
и `Window::PollEvents` исчезли, их место заняли `window_event` и `about_to_wait`. Это
свойство winit, а не Rust: крейт `glfw` существует и сохранил бы прежнюю форму, но он
такие же биндинги к C, как `imgui`.
## Чего в экосистеме Rust не нашлось
**Замены vk-bootstrap нет.** В C++ выбор физического устройства по требуемым
возможностям, поиск семейств очередей, проверка наличия слоя валидации и сборка цепочки
показа — это `vkb::InstanceBuilder`, `vkb::PhysicalDeviceSelector`, `vkb::SwapchainBuilder`,
суммарно около сорока строк вызовов. В порте это `context.rs` и `swapchain.rs`, написанные
руками: перебор устройств, чтение `PhysicalDeviceFeatures2` с цепочкой `Vulkan12/13Features`,
разбор флагов семейств очередей, проверка поддержки показа на поверхность, выбор формата
с откатом, зажим размера и числа образов по возможностям поверхности. Это **самая
большая статья расходов порта** и главная причина, по которой `simv-vk` вышел длиннее
своего прообраза.
**Ритм выпусков разный.** `ash` 0.38 вышел в апреле 2024 и с тех пор не обновлялся —
это не заброшенность (Vulkan 1.3 он покрывает полностью, а генерируемый API стабилен),
но зафиксировать стоит. `egui` 0.36 и `egui-ash-renderer` 0.13 — август 2026, живее
некуда, причём вторая обновилась вслед за первой за две недели. Обратная сторона живости
— поломки: `egui` 0.36 переименовал `Context::run` в `run_ui`, сменил форму
`TexturesDelta` (карта правок вместо одной на текстуру) и объявил `set_textures`
устаревшим. Порт писался под 0.36 и учитывает всё это; годичной давности пример из сети
не собрался бы.
**Мелочи, которые в C++ приходят с библиотекой.** spdlog печатает локальное время из
коробки; в Rust за форматированной меткой пришлось взять `chrono`. GLM определяется
одним заголовком; glam — крейт, зато соглашение о пространстве отсечения у него вынесено
в путь функции (`camera::rh::proj::vulkan::perspective` вместо
`GLM_FORCE_DEPTH_ZERO_TO_ONE` плюс ручной `p[1][1] *= -1`).
## Где какой язык помог, а где помешал
**Rust помешал — заимствования на границе рендерера.** Пункты 4 и 5 выше: конструкция
«объект хранит замыкание, которое трогает этот же объект» в C++ пишется не думая, а в
Rust не компилируется вовсе. Обход занял полчаса и, если честно, дал код лучше исходного
— но полчаса были потрачены на спор с компилятором, а не на задачу.
**Rust помешал — цепочки pNext.** `push_next` держит `&mut` на вложенную структуру, пока
жива голова цепочки, поэтому прочитать `f13.synchronization2` при живом
`PhysicalDeviceFeatures2` компилятор не даёт. В C++ pNext — сырой указатель, и вопроса
нет. Лечится блоком, который гасит заимствование:
```rust
let (f13, f12, f10) = {
let mut f13 = vk::PhysicalDeviceVulkan13Features::default();
let mut f2 = vk::PhysicalDeviceFeatures2::default().push_next(&mut f13) /* … */;
unsafe { instance.get_physical_device_features2(handle, &mut f2) };
(f13, /* … */ f2.features)
};
```
**Rust помог — порядок уничтожения.** В C++ `Renderer::Impl::~Impl` — это тридцать строк
ручного разрушения в правильном порядке, и любая перестановка полей в объявлении структуры
её не сломает, но и не поможет: связи нет. В Rust порядок полей **и есть** порядок
уничтожения, а `Drop` у каждой обёртки снимает вопрос «а это уже освободили?». Ловушка
осталась одна и она подписана в коде: `ash::Device` клонируется как таблица функций, а не
как владеющая ссылка, поэтому контекст обязан объявляться последним.
**Rust помог — ошибки как значения.** `CheckVk(r, where)` в `Renderer.cpp` — это ручная
проверка каждого вызова с бросанием `runtime_error`. В порте её место занял `?` и
трейт-расширение на четыре строки, навешивающее имя вызова. Пропустить проверку нельзя:
`#[must_use]` на `VkResult` не даст.
**Rust помог — незагруженный меш.** В C++ это `GpuMesh` с состоянием «пустой», метод
`IsValid()` и отдельный флаг `hasMesh` в `Renderer`, которые обязаны быть согласованы.
В порте — `Option<GpuMesh>`, и рассогласовать нечего.
**Ничья — сам Vulkan.** Записи `vkCmdBeginRendering`, барьеры sync2, сборка конвейеров
выглядят в обеих версиях почти одинаково, строка в строку. `ash` не пытается быть
безопасным поверх Vulkan, и это правильно: `unsafe`-блоки локализуют ровно те места, где
C++ тоже опасен, не притворяясь, что их нет.
## Итог
Ни один из замеров не даёт разницы в порядок величины, так что решение к таблице не
сводится. Что можно утверждать по итогам порта:
**Порт занял примерно на пятую часть больше строк**, и почти весь перевес пришёлся на
`simv-vk`. Причина одна и она названа выше: замены vk-bootstrap нет, и сорок строк
вызовов превратились в двести пятьдесят строк выбора устройства и сборки цепочки показа.
Вне Vulkan-слоя объёмы сопоставимы. Если проект растёт дальше именно в Vulkan-часть,
перевес разовый: инициализация пишется один раз.
**Сборка: паритет везде, кроме одной клетки.** Полная сборка release у C++ вдвое быстрее
(114 с против 194 с), полная debug — наоборот, медленнее (112 с против 83 с),
инкрементальная в debug у Rust быстрее (5.2 с против 8.0 с). Единственная резкая
клетка — инкрементальная release: 52 с против 4.4 с. Это цена thin LTO, а не языка: со
сравнимым уровнем оптимизации (LTO выключен, как у MSVC `/O2` без LTCG) те же 5.5 с
против 4.4 с. Практический вывод простой — держать в профиле разработки LTO выключенным,
включая его только для выпуска.
**Четыре дефекта в C++-версии нашлись при переносе, и ни один не искали.** Цикл в графе
целей CMake, который проходит только из-за незалинкованной цели. Выгрузка меша с
`vkDeviceWaitIdle` посреди записи кадра. Рассогласование `GpuMesh::IsValid()` и флага
`hasMesh` — два источника правды об одном. Строка `Mesh ready`, которую троттлинг
категории `MeshIO` отбрасывает всегда, так что счётчики после сварки не печатались
никогда. Первое стало ошибкой сборки, второе — невозможным, третье — невыразимым,
четвёртое всплыло при сверке чисел.
**И двести строк мёртвого кода.** `Buffer` и `Image` собираются каждую сборку и никем не
используются. Это не про язык, а про то, что переписывание само по себе работает как
аудит.
**Цена — время на границах.** Замыкание, которое трогает объект, из чьего метода оно
вызвано; цепочки pNext, держащие заимствование; порядок уничтожения полей. Каждое место
решается за полчаса, каждое решение оказывается не хуже исходного, но полчаса уходят на
разговор с компилятором, а не на задачу.
**Экосистема разнородна.** `egui` и его Vulkan-бэкенд выпускаются раз в месяц и ломают
API; `ash` не менялся два года и не собирается. Замены vk-bootstrap нет вовсе. В C++
FetchContent тянет девять библиотек по фиксированным тегам и не имеет общего кэша: каждый
свежий каталог сборки скачивает их заново.
Выбор языка — за вами. Этот документ фиксирует только то, что удалось измерить и показать
на коде.