Files
CFDManager/docs/rust_vs_cpp.md
T
NotBigGhostandClaude Opus 5 d6e1fa52b9 Уточнить цену замены vk-bootstrap измеренными числами
Было сказано «около сорока строк вызовов» на глаз. Счёт по одним и тем же
модулям (Context и Swapchain целиком, код без пустых и комментариев) даёт
301 строку в C++ против 606 в порте.

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

32 KiB
Raw Blame History

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 с матрицей вида-проекции, режимом и видимостью сетки. Состояние применяется к тому же кадру, отставания нет. Сеттеры исчезли за ненадобностью.

// C++: состояние ставится изнутри колбэка
app.SetUiCallback([&]() {
    editorUI.Draw(camera);
    renderer.SetViewProj(camera.ViewProj(renderer.AspectRatio()));
});
// 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. В порте всё это написано руками: перебор устройств, чтение PhysicalDeviceFeatures2 с цепочкой Vulkan12/13Features и сверка девяти возможностей, разбор флагов семейств очередей с проверкой поддержки показа на поверхность, выбор формата с откатом на первый доступный, зажим размера и числа образов по возможностям поверхности, создание представлений.

Счёт по одним и тем же модулям (Context и Swapchain целиком, строки кода без пустых и комментариев): 301 строка в C++ против 606 в порте. Эти 305 строк разницы и есть главная причина, по которой simv-vk вышел длиннее прообраза, — весь остальной перевес Vulkan-слоя укладывается в четыре десятка строк.

Ритм выпусков разный. 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 — сырой указатель, и вопроса нет. Лечится блоком, который гасит заимствование:

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 нет, и 301 строка Context с Swapchain превратилась в 606. Остальной 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 тянет девять библиотек по фиксированным тегам и не имеет общего кэша: каждый свежий каталог сборки скачивает их заново.

Выбор языка — за вами. Этот документ фиксирует только то, что удалось измерить и показать на коде.