Условия, цифры и разбор по коду. Замеры на одной машине подряд: сборка (полная, тёплая, инкрементальная — в 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>
32 KiB
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.obj121 грань с пятью и более вершинами. 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,
суммарно около сорока строк вызовов. В порте это 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 — сырой указатель, и вопроса
нет. Лечится блоком, который гасит заимствование:
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 тянет девять библиотек по фиксированным тегам и не имеет общего кэша: каждый
свежий каталог сборки скачивает их заново.
Выбор языка — за вами. Этот документ фиксирует только то, что удалось измерить и показать на коде.