Сравнение 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>
This commit is contained in:
2026-08-30 20:58:33 +03:00
co-authored by Claude Opus 5
parent f5c9e61589
commit 1d7f9191ea
+395
View File
@@ -0,0 +1,395 @@
# 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 тянет девять библиотек по фиксированным тегам и не имеет общего кэша: каждый
свежий каталог сборки скачивает их заново.
Выбор языка — за вами. Этот документ фиксирует только то, что удалось измерить и показать
на коде.