7 Commits
Author SHA1 Message Date
NotBigGhostandClaude Opus 5.5 1d495e3e89 Кампания на пять равных долей: контейнер сразу считает свою часть
gen_scenarios.py раскладывает 115 прогонов по пяти долям (LPT, поправка
на размер сетки по замерам dzn) — по ≈24 ч каждая; поле shard в
scenarios.json. run_campaign.sh/.ps1 получили --shard. Новая точка входа
образа entrypoint.sh: при KBC2D_SHARD сама выбирает путь к карте (dzn
или NVIDIA), проверяет vulkaninfo и parity.py и запускает долю. Для
чужих ПК под Windows/WSL2 — docker-compose.shards.yml и shard.bat.
Образ notbigghost/kbc2d:1.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:15:40 +03:00
NotBigGhostandClaude Opus 5 3e81ed130c Сверка документации с кодом: исправлены расхождения
Сплошная проверка документов против дерева. Код не менялся.

README.md решателя:
- компенсированное суммирование Кэхена–Ноймайера заявлено «в редукциях
  и в сумме сил», но ядро k_force копит в обычном f32 и сворачивает
  наивным деревом; kadd применяется только в редукциях статистики;
- «каждый прогон кладёт гифку» — гифку пишут 59 прогонов из 115,
  остальным --gif не передаётся; перечислено, что пишется всегда;
- «≈90 часов GPU» — это ≈90 машинных часов: 108 прогонов на GPU (≈87 ч)
  и 7 на CPU (≈3 ч, сходимость группы A).

bench/README.md — те же две поправки про часы и гифки.

docs/theory/solver_2x_sdf/README.md:
- предлагался несуществующий переключатель cfg.collision="bgk" и реестр
  операторов get; тот же файл двумя разделами ниже говорит, что оператор
  зафиксирован. Правка перенесена из ветки rust, файл общий для всех
  веток.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:12:25 +03:00
NotBigGhostandClaude Opus 5 251293cd05 CLAUDE.md вне версионирования: один файл на все ветки
Файл описывает всё дерево целиком, а ветки содержат разные его части
(порт на Rust, исследование CFD, базовый C++-редактор). Отслеживаемая
копия при каждом переключении подменялась бы версией своей ветки, тогда
как нужна одна общая. Содержимое на всех ветках было идентично, так что
расхождений открепление не теряет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9Gcr11JJJyf8NnWsXjDzZ
2026-09-06 16:25:03 +03:00
NotBigGhostandClaude Opus 5 d5e37fcb8d Раздельные привязки популяций: снят предел dzn на размер сетки
Массив популяций занимает nx*ny*9*4 байта и показывался шейдеру одной привязкой.
dzn объявляет max_storage_buffer_binding_size = 128 МиБ, поэтому потолок выходил
3.73 млн узлов: 14 прогонов кампании из 115 падали на создании bind group, а на
них приходится 70.7% её стоимости.

Ограничена при этом ровно привязка: max_buffer_size у dzn 2047 МиБ. Поэтому тот
же буфер теперь показывается девятью привязками, по одному направлению в каждой,
и потолок поднимается до 33.5 млн узлов — самая крупная сетка кампании
(4096x4096, 16.8 млн) проходит с запасом.

Как устроено:
 * шаг между направлениями выровнен на 256 байт (dir_stride), потому что смещение
   привязки обязано быть кратно min_storage_buffer_offset_alignment;
 * обращения к популяциям в WGSL идут через fget/fset/pget/pset, а их тело
   генерируется под вариант (build_shader);
 * вариант выбирается по max_storage_buffer_binding_size адаптера — где предела
   нет, собирается прежний общий, без switch в аксессорах;
 * KBC2D_SPLIT_POPULATIONS=1 включает раздельные принудительно: иначе сверить два
   варианта на одной карте нечем.

Проверено:
 * на одном драйвере оба варианта дают одно и то же — Cd 2.39486, energy_end
   5.74813e-05; раздельный стоит 5.7% пропускной способности;
 * на сетке 2048x2048, где раздельные привязки и нужны, нативный прогон против
   контейнерного: energy_end расходится на 2.4e-07, enstrophy_end на 1.3e-07;
 * preflight в контейнере: 115 сценариев из 115, ни одного отказа (было 14);
 * 29 собственных тестов решателя зелёные.

Образ опубликован как notbigghost/kbc2d:1.2.0 (он же latest).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:08:12 +03:00
NotBigGhostandClaude Opus 5 6344c7d205 Внятные сообщения preflight и предел dzn на размер привязки
preflight печатал последнюю строку вывода упавшего прогона, а у паники Rust
последняя строка — «note: run with RUST_BACKTRACE=1», то есть ноль сведений о
причине. Теперь из вывода достаётся собственное сообщение решателя, а для паники —
то, что стоит ПОСЛЕ строки «panicked at». Вместо

  A10_turb_n2048_kbc   note: run with `RUST_BACKTRACE=1` ...

печатается

  A10_turb_n2048_kbc
      wgpu error: Validation Error | In Device::create_bind_group, label = 'level'
      | Buffer binding 0 range 150994944 exceeds `max_*_buffer_binding_size` limit 134217728

Заодно задокументировано само ограничение. dzn объявляет
max_storage_buffer_binding_size = 128 МиБ против гигабайтов у нативных драйверов,
при том что max_buffer_size у него 2047 МиБ: держать большой буфер можно, показать
шейдеру одной привязкой — нет. Потолок выходит 3.73 млн узлов (около 1920x1920),
и 14 прогонов кампании из 115 в него не влезают. На эти 14 приходится 70.7%
стоимости, так что для кампании ограничение решающее.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:35:07 +03:00
NotBigGhostandClaude Opus 5 81985b169e Запуск в WSL2 через docker compose без NVIDIA-runtime
В WSL2 драйвера Vulkan для Linux у NVIDIA нет: карта отдаётся через /dev/dxg по
протоколу WDDM, нативный libGLX_nvidia про него не знает и перечисляет ноль
устройств, поэтому Container Toolkit нечего подкладывать внутрь и docker падает
с `could not select device driver "nvidia"`.

С /dev/dxg умеет говорить dzn (Dozen) — драйвер Mesa, транслирующий Vulkan в
D3D12. Он положен в образ, и NVIDIA-runtime для этого пути не нужен вовсе: нужны
проброс устройства и монтирование /usr/lib/wsl, где Microsoft держит libd3d12.so.

Правка в решателе одна: флаги инстанса wgpu теперь читаются из окружения
(InstanceFlags::from_build_config().with_env()). Без этого переменная
WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER не действует, а wgpu по умолчанию
МОЛЧА прячет адаптеры, не прошедшие тесты соответствия Vulkan, — под это правило
попадает dzn, и решатель сообщал, что GPU не найден. Поведение по умолчанию не
изменилось: без переменной такие адаптеры по-прежнему скрыты.

База образа сменена с debian:bookworm-slim на archlinux:base: в пакетах Mesa у
Debian и Ubuntu dzn не собирают (проверено по спискам файлов), в Arch он лежит
отдельным пакетом той же версии Mesa, что и на хосте WSL.

Новое:
 * docker-compose.wsl.yml — путь через /dev/dxg, с профилем проверок и с build:
   на случай, когда доступа к реестру нет;
 * bench/parity.py — сверка GPU-пути с CPU в f64 на течениях, где расхождение
   f32 и f64 не нарастает. Соответствие dzn вендором не проверено, значит
   проверяем сами, а не верим на слово.

Замерено на Intel Iris Xe (та же карта, нативный драйвер против dzn):
 * точность: cd 2.39486 против 2.39486, energy_end 5.74813e-05 против
   5.74810e-05 — совпадение до 5-6 значащих цифр;
 * скорость: плата за трансляцию падает с ростом сетки, 4.2x на 61 тыс. узлов,
   1.46x на 461 тыс., 1.30x на 1.84 млн. На 95.1% стоимости кампании сетки
   крупнее 600 тыс. узлов, поэтому ожидаемое удорожание — около трети, не в разы.

В калибровку добавлена сетка 1920x960: оценивать кампанию по 240x120 значит
занижать пропускную способность вчетверо. Образ опубликован как
notbigghost/kbc2d:1.1.0 (он же latest), проверен вытягиванием из реестра.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:15:53 +03:00
NotBigGhostandClaude Opus 5 e75b404f8a Опубликовать образ в реестр и добавить compose для сервера
Образ собран под linux/amd64 и выложен как notbigghost/kbc2d — теги 1.0.0,
latest и 8d17bc8 указывают на один digest sha256:932d881d. В Dockerfile
добавлены метки OCI: версия, хеш коммита и ссылка на репозиторий, чтобы
образ на сервере однозначно сопоставлялся с состоянием исходников.

docker-compose.server.yml самодостаточен: тянет готовый образ из реестра,
исходников не требует, на сервер переносится одним файлом. Кампания —
единственный сервис, поднимающийся по up -d; проверки (vulkaninfo,
preflight, calibrate, dry-run) вынесены в профиль check и сами не
стартуют. Порядок проверок задан документацией: карта, сценарии,
калибровка, смета — и только потом счёт.

Политика перезапуска on-failure, а не unless-stopped: кампания завершается
штатно с кодом 0, и «перезапускать всегда» крутило бы контейнер вхолостую
по кругу, тогда как падение и перезагрузку хоста on-failure подхватывает,
а --resume продолжает с места. Журнал ограничен по размеру: девяносто
часов вывода иначе съедят диск, полные логи каждого прогона всё равно
лежат в out/<id>/log.txt.

Проверено локально из каталога без исходников: образ тянется из реестра,
смоук проходит, результаты ложатся на хост. Часть с картой проверяема
только на сервере — здесь запрос устройства ожидаемо отвергается
«no adapters were found», что само по себе подтверждает, что резервация
GPU в compose действует.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:15:01 +03:00
18 changed files with 1607 additions and 453 deletions
+3
View File
@@ -69,3 +69,6 @@ docs/theory/2d_solver/bench/out/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
desktop.ini desktop.ini
# Общие заметки Claude Code: один файл на все ветки, вне версионирования
/CLAUDE.md
-202
View File
@@ -1,202 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**SimVulcan** is a minimal **Vulkan 1.3 / C++20** 3D model editor. It renders a
3D editor space — three reference grid planes (XY, XZ, YZ) through the origin plus
coloured X/Y/Z axes — viewed through an orbit camera, and loads/displays `.obj`
models with selectable display modes (solid, wireframe, solid + wireframe).
The C++ side runs no simulation: it is a focused rendering skeleton with an ImGui
interface (a Viewport control panel and a Mesh load panel). The repository *also*
carries an unrelated Python research prototype under `docs/theory/` — see
[Research prototype](#research-prototype-docstheory) below; it is not part of the
CMake build.
## Build
Prerequisites: **Vulkan SDK 1.3.290+** (provides `glslangValidator` for offline
shader compilation), **CMake 3.26+**, **Ninja**, a C++20 compiler (MSVC 19.36+,
gcc 11+, clang 14+). On macOS, Vulkan is via MoltenVK (Apple Silicon only).
```sh
cmake --preset windows-msvc-release
cmake --build --preset windows-msvc-release
```
Presets: `windows-msvc-debug`, `windows-msvc-release`, `linux-gcc-release`,
`linux-clang-release`, `macos-arm64-release` (all Ninja, one dir per preset under
`build/<presetName>/`). The first configure fetches dependencies via FetchContent
(GLFW, GLM, volk, vk-bootstrap, VulkanMemoryAllocator, Dear ImGui, spdlog,
tinyobjloader, Catch2) and needs network access. `VK_NO_PROTOTYPES` is set
project-wide; Vulkan entry points load through **volk**.
Warnings come from `simv_set_warnings` (`/W4 /permissive-`, or
`-Wall -Wextra -Wpedantic -Wshadow -Wold-style-cast …`); they are **not** errors.
`cmake/Sanitizers.cmake` defines `simv_enable_sanitizers` (Debug-only ASan/UBSan)
but no target currently calls it — wire it in manually when chasing memory bugs.
## Running
The executable resolves SPIR-V relative to the working directory (`FindSpvPath`
probes `spirv/<rel>` and `current_path()/spirv/<rel>`). The `SimVulcan` POST_BUILD
step copies the compiled `spirv/` tree and `assets/` next to the executable, so
**run from the executable's own directory** (`build/<preset>/src/app/`). `main.cpp`
also probes several `../` ancestors for `assets/meshes`. Meshes load at runtime
through the ImGui Mesh panel.
Running writes two files into the CWD: `pipeline_cache.bin` (serialised
`VkPipelineCache`, reloaded on the next start) and ImGui's `imgui.ini`. Both are
disposable — delete them if pipeline creation or the panel layout misbehaves.
`ContextOptions::enableValidation` / `enableDebugUtils` default to **true** in
every build config, so the Khronos validation layer is requested even in Release;
messages (error + warning severity) go through spdlog.
`run.bat` at the repo root is **stale**: it launches
`build/vs2022/src/app/Release/SimVulcan.exe`, a path the Ninja presets never
produce. Do not point users at it without fixing the path first.
## Tests
Catch2 unit tests, pure CPU/math (mesh bounds + welding). No GPU required.
```sh
cmake --build --preset windows-msvc-debug --target simv_tests
ctest --preset windows-msvc-debug
```
`windows-msvc-debug` is the only preset with a `testPreset` — for the other
configs, invoke `ctest` in `build/<preset>/` directly.
Single test / subset — either through CTest (each `TEST_CASE` is registered
individually by `catch_discover_tests`):
```sh
ctest --preset windows-msvc-debug -R "WeldVertices" -V
```
or by running the binary with a Catch2 name or tag filter:
```sh
./build/windows-msvc-debug/tests/simv_tests.exe "[decimator]"
./build/windows-msvc-debug/tests/simv_tests.exe --list-tests
```
## Architecture
### Library / target map
```
SimVulcan (exe) → simv_core, simv_vk, simv_mesh, simv_editor
simv_core → simv_vk (App owns Window + vk::Renderer; no Vulkan calls)
simv_editor → simv_core, simv_mesh (Camera, input, ImGui panels; no Vulkan)
simv_mesh → simv_core (CPU mesh only; no Vulkan)
simv_vk → third-party (volk, vk-bootstrap, VMA, GLFW, GLM, spdlog, imgui)
simv_shaders → glslangValidator (GLSL → SPIR-V)
```
Namespaces follow directories: `simv::core`, `simv::vk`, `simv::mesh`,
`simv::editor`. Each library exports `src/` as its include root, so includes are
written module-qualified (`#include "mesh/Mesh.h"`, `#include "vk/Renderer.h"`).
### Frame loop
`main.cpp` creates `core::App`, which owns the `core::Window` and a
`vk::Renderer`, then runs the loop. Each frame `Renderer::DrawFrame` calls the UI
callback (between ImGui NewFrame/Render), then records the scene: grid + mesh into
one dynamic-rendering pass with a depth attachment, followed by ImGui, then
presents (2 frames in flight, sync2 submits).
`main.cpp` wires the editor via the UI callback: it draws the panels, applies
mouse input to the `editor::Camera`, and pushes the resulting state into the
renderer (`SetViewProj`, `SetRenderMode`, `SetGridVisible`). Mesh loads go through
`MeshLoadPanel`'s callback → `Renderer::SetMeshCpu`.
### Mesh load path
`MeshLoadPanel` lists `*.obj` in the mesh directory and kicks off
`mesh::LoadObjAsync` (worker thread, `std::future`). The future is **drained on
the main thread** at the top of `MeshLoadPanel::Draw`, so the `OnLoaded` callback
— and therefore the GPU upload — always runs on the render thread. Loader
exceptions surface as the panel's status string.
`main.cpp`'s `OnLoaded` welds the mesh (`WeldVertices`, tolerance `1e-4`), logs
the counts, uploads via `SetMeshCpu`, and reframes the camera to the bbox radius.
Despite the file name, `mesh/MeshDecimator.h` implements **only** spatial-hash
vertex welding (collapse near-duplicates, drop degenerate triangles, recompute
bounds) — there is no LOD/decimation.
### Non-obvious invariants (read before editing)
- **All Vulkan lives in `src/vk/`.** `core/`, `mesh/`, `editor/` and `app/` make no
Vulkan API calls. `vk::Renderer`'s public header is deliberately Vulkan-free
(pImpl + glm/`RenderMode` only) so `App` can own it without pulling in volk. Keep
it that way — do not leak `Vk*` types into the public interfaces of those modules.
- **Single render pass with depth.** Scene (grid + mesh) and ImGui draw into one
`vkCmdBeginRendering` pass that has both a colour and a `D32_SFLOAT` depth
attachment. The ImGui backend is initialised with `depthAttachmentFormat` set so
its pipeline matches the pass; the depth buffer is recreated with the swapchain.
- **Wireframe needs `fillModeNonSolid`.** `MeshRenderer` builds a fill pipeline and
a `VK_POLYGON_MODE_LINE` pipeline; the line pipeline uses a small depth bias so
the overlay sits on top of the fill. The device feature is requested in `Context`.
Culling is off (`VK_CULL_MODE_NONE`) — loaded models may have mixed winding.
- **Camera is fixed on the origin.** `editor::Camera` orbits (yaw/pitch/distance)
the world origin; loaded models are recentred there via a translate-only model
matrix. Projection uses Vulkan clip space (`GLM_FORCE_DEPTH_ZERO_TO_ONE` + Y flip,
isolated to `Camera.cpp`).
- **Buffers.** `vk::GpuMesh` owns the model's vertex/index buffers (staged upload on
the transfer queue). `GridRenderer` builds a static host-visible line buffer once.
Both scene renderers use only a push constant — no descriptor sets.
- **Push-constant layout is a cross-file contract.** `MeshRenderer.cpp`'s anonymous
`MeshPC { mat4 mvp; vec4 color; }` must stay byte-identical to the
`push_constant` block in `mesh.vert`/`mesh.frag`; `color.a` is a *flag*, not
alpha (1 = flat-shaded, 0 = constant colour for wireframe). `GridRenderer` pushes
a bare `mat4` (vertex stage only). Change either side and you must change both.
- **Swapchain recreation rebuilds sync objects.** `RecreateSwapDependent` recreates
the per-frame `imageAvailable` semaphores (a failed acquire can leave one
signalled) *and* the per-swapchain-image `renderFinished` semaphores (the image
count may change), then re-ensures both pipelines. Keep that ordering if you
touch resize handling.
- **Mesh upload stalls the device.** `SetMeshCpu`/`ClearMesh` call `WaitIdle`
before touching `GpuMesh` — acceptable because loads are rare; do not copy that
pattern into per-frame paths.
### Shaders
GLSL under `shaders/editor/` (`mesh.{vert,frag}`, `grid.{vert,frag}`). `simv_shaders`
compiles each to `build/<preset>/spirv/editor/<name>.spv` targeting `vulkan1.3`,
with `shaders/` as the `-I` root (so `#include "common/foo.glsl"` would resolve).
Adding a shader under that globbed dir is picked up automatically
(`CONFIGURE_DEPENDS`). `mesh.frag` reconstructs a flat normal from screen-space
derivatives, so the vertex stream carries only positions (tight `float3`, one
binding, one attribute).
## Logging
`simv::core::Logger` (spdlog-backed, singleton) with category-based throttling.
Log via `LogFmt(LogCategory, LogLevel, fmt, args...)`. Categories: `Core`,
`Vulkan`, `MeshIO`, `UI`, `Test`. Per-category level, throttle interval and
on/off are settable at runtime (`SetMinLevel` / `SetThrottle` / `SetEnabled`).
Some low-level Vulkan code still calls `spdlog::` directly.
## Research prototype (`docs/theory/`)
Separate from the C++ application and from the CMake build: a Python **Lattice
Boltzmann (D2Q9, KBC-N1)** research codebase — cylinder flow with a ×2 nested AMR
patch and SDF+Bouzidi boundaries. **GPU/CuPy only, no CPU fallback.** Its
documentation and the running experiment log are in Russian.
- `docs/theory/solver_2x_sdf/` — the component-split solver (`run.py`,
`run_blockage.py`, `run_factors.py`); its `README.md` is the authoritative
status/experiment log and maps every file to the physics it owns.
- `docs/theory/demos_gpu/` — demo runs that produce the notebook's figures/GIFs;
they import physics from `solver_2x_sdf`, never duplicate it.
- `docs/theory/kbc_lbm.ipynb` — the write-up; it embeds pre-rendered artefacts and
does not execute the simulations.
- `docs/origins/` — source PDFs behind the theory.
Do not fold this into the CMake build, and do not treat it as dead code — it is
active research with a documented experiment history.
+51 -21
View File
@@ -1,15 +1,23 @@
# Образ для развёртывания решателя и валидационной кампании на сервере с NVIDIA. # Образ решателя и валидационной кампании.
# #
# Сборка и запуск: # Поддерживает два способа добраться до видеокарты, и выбирать между ними не нужно —
# доступным окажется ровно один:
#
# 1. НАСТОЯЩИЙ LINUX-СЕРВЕР с драйвером NVIDIA. Vulkan-ICD подкладывает внутрь
# NVIDIA Container Toolkit, и только если в NVIDIA_DRIVER_CAPABILITIES есть
# `graphics` — с одним `compute` wgpu не увидит ни одного адаптера. Запуск через
# docker-compose.server.yml.
#
# 2. WSL2. Драйвер NVIDIA для Linux там отсутствует как класс: карта отдаётся через
# /dev/dxg по протоколу WDDM, а нативный libGLX_nvidia про него не знает и
# enumerate возвращает ноль устройств. Зато с /dev/dxg умеет говорить dzn (Dozen) —
# драйвер Mesa, транслирующий Vulkan в D3D12. Он в образе и лежит. NVIDIA Container
# Toolkit при этом НЕ НУЖЕН: нужен проброс устройства и монтирование /usr/lib/wsl,
# где Microsoft держит libd3d12.so. Запуск через docker-compose.wsl.yml.
#
# Сборка и быстрая проверка:
# docker build -t kbc2d docs/theory/2d_solver # docker build -t kbc2d docs/theory/2d_solver
# docker run --rm --gpus all kbc2d --calibrate # проверить, что GPU виден # docker compose -f docker-compose.wsl.yml --profile check run --rm vulkan
# docker run -d --gpus all -v "$PWD/out:/work/bench/out" --name kbc2d kbc2d --resume
#
# ГЛАВНАЯ ТОНКОСТЬ — Vulkan в контейнере. NVIDIA Container Toolkit подкладывает внутрь
# Vulkan-ICD (nvidia_icd.json) только если в NVIDIA_DRIVER_CAPABILITIES есть `graphics`.
# С одним `compute` wgpu не увидит НИ ОДНОГО адаптера и решатель откажется стартовать с
# --backend gpu. Поэтому capabilities прописаны в образе, а vulkan-tools положен внутрь,
# чтобы первым делом можно было выполнить `vulkaninfo` и убедиться, что карта видна.
# ── сборка ─────────────────────────────────────────────────────────────────── # ── сборка ───────────────────────────────────────────────────────────────────
# Версия тулчейна закреплена: та же, на которой решатель собирался и проверялся. # Версия тулчейна закреплена: та же, на которой решатель собирался и проверялся.
@@ -18,34 +26,56 @@ WORKDIR /src
# Сначала только манифесты — тогда слой с зависимостями переиспользуется, пока они не менялись. # Сначала только манифесты — тогда слой с зависимостями переиспользуется, пока они не менялись.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs \ RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release --locked 2>/dev/null || true && rm -rf src
&& cargo build --release --locked 2>/dev/null || true \
&& rm -rf src
COPY src ./src COPY src ./src
# touch нужен, чтобы cargo не спутал новые исходники с заглушкой из слоя выше # touch нужен, чтобы cargo не спутал новые исходники с заглушкой из слоя выше
RUN touch src/main.rs && cargo build --release --locked RUN touch src/main.rs && cargo build --release --locked
# ── исполнение ─────────────────────────────────────────────────────────────── # ── исполнение ───────────────────────────────────────────────────────────────
FROM debian:bookworm-slim # База Arch, а не debian-slim, по одной причине: dzn собирают не все дистрибутивы.
# В mesa-vulkan-drivers Debian и Ubuntu его нет (проверено по списку файлов пакета),
# в Arch он лежит отдельным пакетом vulkan-dzn той же версии Mesa, что и на хосте WSL.
FROM archlinux:base
ARG VERSION=dev
ARG REVISION=unknown
LABEL org.opencontainers.image.title="kbc2d" LABEL org.opencontainers.image.title="kbc2d"
LABEL org.opencontainers.image.description="Двумерный решатель LBM D2Q9 с энтропийным столкновением KBC" LABEL org.opencontainers.image.description="Двумерный решатель LBM D2Q9 с энтропийным столкновением KBC"
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.revision="${REVISION}"
LABEL org.opencontainers.image.source="https://gitea.arseniev.info/NotBigGhost/TurbulenceCAD"
RUN apt-get update && apt-get install -y --no-install-recommends \ # Справка, руководства и переводы занимают в базе Arch заметно больше, чем сам
libvulkan1 vulkan-tools python3 ca-certificates \ # драйвер, а в контейнере не нужны никому. NoExtract прописывается ДО установки.
&& rm -rf /var/lib/apt/lists/* RUN { echo 'NoExtract = usr/share/man/*'; \
echo 'NoExtract = usr/share/doc/*'; \
echo 'NoExtract = usr/share/info/*'; \
echo 'NoExtract = usr/share/locale/*'; \
echo 'NoExtract = usr/share/i18n/*'; } >> /etc/pacman.conf \
&& pacman -Syu --noconfirm --needed \
vulkan-icd-loader vulkan-dzn vulkan-tools python \
&& pacman -Scc --noconfirm \
&& rm -rf /var/cache/pacman/pkg/* /var/lib/pacman/sync/* \
/usr/share/man /usr/share/doc /usr/share/info /usr/share/locale
# Без graphics в списке возможностей Vulkan-ICD внутрь не попадёт — см. шапку. # Проверка на этапе сборки: без dzn образ бесполезен для WSL, и узнать об этом надо
# здесь, а не через час после старта кампании.
RUN ls /usr/share/vulkan/icd.d/ && ls /usr/share/vulkan/icd.d/dzn_icd*.json > /dev/null
# Для пути 1 (настоящий сервер). На WSL эти переменные ни на что не влияют.
ENV NVIDIA_VISIBLE_DEVICES=all ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics
WORKDIR /work WORKDIR /work
COPY --from=build /src/target/release/kbc2d /work/target/release/kbc2d COPY --from=build /src/target/release/kbc2d /work/target/release/kbc2d
COPY bench /work/bench COPY bench /work/bench
RUN chmod +x /work/bench/run_campaign.sh RUN chmod +x /work/bench/run_campaign.sh /work/bench/entrypoint.sh
# Кампания идёт десятки часов, поэтому по умолчанию образ НЕ запускает её: случайный # Кампания идёт десятки часов, поэтому по умолчанию образ НЕ запускает её: случайный
# `docker run` покажет смету и выйдет. # `docker run` покажет смету и выйдет. Иначе — если задана доля (KBC2D_SHARD=1..5, так делает
# docker-compose.shards.yml): тогда entrypoint.sh сам проверяет карту, сверяет её с CPU и
# сразу начинает считать свою часть.
WORKDIR /work/bench WORKDIR /work/bench
ENTRYPOINT ["./run_campaign.sh"] ENTRYPOINT ["./entrypoint.sh"]
CMD ["--dry-run"] CMD ["--dry-run"]
+111 -14
View File
@@ -364,8 +364,11 @@ CPU (f64) и GPU (f32) на одной постановке совпадают
статистики: на 240×120 счёт упирался в 863 шаг/с при том, что сам счёт занимал 0.27 мс из статистики: на 240×120 счёт упирался в 863 шаг/с при том, что сам счёт занимал 0.27 мс из
1.16. Теперь итоги копятся в кольце на 128 слотов, синхронизация — раз в батч: **6715 шаг/с**, 1.16. Теперь итоги копятся в кольце на 128 слотов, синхронизация — раз в батч: **6715 шаг/с**,
в 7.8 раза быстрее, при неизменном пошаговом интерфейсе снаружи. в 7.8 раза быстрее, при неизменном пошаговом интерфейсе снаружи.
2. **Компенсированное суммирование** (Кэхена–Ноймайера) в редукциях и в сумме сил. Наивная сумма 2. **Компенсированное суммирование** (Кэхена–Ноймайера) в редукциях статистики — ⟨ρ⟩, ⟨γ⟩,
по 10⁵–10⁷ узлам съедает ~log₂N бит мантиссы — именно там f32 терял основную точность. счётчики узлов и вырожденных узлов. Наивная сумма по 10⁵–10⁷ узлам съедает ~log₂N бит
мантиссы — именно там f32 терял основную точность. **Ядро сил компенсации не использует:**
`k_force` копит в обычном f32 и сворачивает наивным деревом. Ранние редакции этого README
утверждали обратное; если точность Cd когда-нибудь упрётся в потолок, начинать надо отсюда.
### Обтекание цилиндра против литературы ### Обтекание цилиндра против литературы
@@ -469,11 +472,13 @@ rms Cl 0.81 вместо 0.40 — то есть 75-процентная прод
## Валидационная кампания ## Валидационная кампания
`bench/` — 115 прогонов на ≈90 часов GPU, разложенных по девяти группам: эталоны первоисточников, `bench/` — 115 прогонов на ≈90 машинных часов, разложенных по девяти группам: эталоны
цилиндр против литературы, модели стенки, профили крыла, сложная и множественная геометрия, первоисточников, цилиндр против литературы, модели стенки, профили крыла, сложная и
границы домена, старт и время жизни, внутренние инварианты, сверхмелкие сетки до 4096×2048. множественная геометрия, границы домена, старт и время жизни, внутренние инварианты,
Каждый прогон кладёт логи, ряды, машиночитаемую сводку и гифку на всю свою длительность в сверхмелкие сетки до 4096×2048. Из них 108 идут на GPU (≈87 часов) и 7 — на CPU (≈3 часа,
собственную папку. исследования сходимости группы A). Каждый прогон кладёт в собственную папку `cmd.txt`,
`log.txt`, `report.txt`, `series.csv` и `summary.json`; гифку на всю свою длительность
пишут **59 прогонов из 115**, остальным `--gif` не передаётся вовсе.
```sh ```sh
cd bench cd bench
@@ -492,14 +497,47 @@ python preflight.py # каждый сценарий старту
## Развёртывание (Docker) ## Развёртывание (Docker)
Собранный образ опубликован: **`notbigghost/kbc2d:1.3.0`** (он же `latest`, платформа `linux/amd64`).
Исходники на сервере не нужны — достаточно перенести туда один файл
`docker-compose.server.yml`:
```sh ```sh
docker build -t kbc2d docs/theory/2d_solver mkdir -p ~/kbc2d && cd ~/kbc2d # сюда же ляжет ./out с результатами
docker run --rm --gpus all kbc2d --calibrate # проверить, что GPU виден # перенести docker-compose.server.yml
docker run -d --gpus all -v "$PWD/out:/work/bench/out" kbc2d --resume
docker compose -f docker-compose.server.yml --profile check run --rm vulkan # карта видна?
docker compose -f docker-compose.server.yml --profile check run --rm preflight # сценарии стартуют?
docker compose -f docker-compose.server.yml --profile check run --rm calibrate # сколько MLUPS?
docker compose -f docker-compose.server.yml up -d # кампания
docker compose -f docker-compose.server.yml logs -f
``` ```
`ENTRYPOINT` — драйвер кампании, `CMD` по умолчанию `--dry-run`: случайный `docker run` покажет Порядок именно такой: узнать, что карта не видна, лучше через минуту, чем через час. Замеренные
смету и выйдет, а не запустит сточасовую задачу. `--calibrate` числа подставляются переменными `KBC2D_GPU_MLUPS` / `KBC2D_CPU_MLUPS` — только на
оценки в часах, на счёт они не влияют.
Собрать образ самому (`docker-compose.yml` рядом делает то же самое с `build:`):
```sh
docker build -t kbc2d docs/theory/2d_solver
docker run --rm --gpus all kbc2d --calibrate
```
### Пять долей на пяти машинах
Кампания разложена на **пять равных по времени долей** (≈24 ч каждая, поле `shard` в
`scenarios.json`), чтобы считать её одновременно на пяти чужих ПК с видеокартой под Windows
или WSL2. На машину переносятся два файла — `docker-compose.shards.yml` и `shard.bat` — и
выполняется `shard.bat 3` (или `docker compose -f docker-compose.shards.yml up -d shard3`).
Контейнер сам проверяет карту, сверяет её с CPU-эталоном и сразу считает свою долю. Подробно —
`bench/README.md`, раздел «Пять долей».
`ENTRYPOINT` — `bench/entrypoint.sh`. Без `KBC2D_SHARD` он просто передаёт аргументы драйверу
кампании, и `CMD` по умолчанию `--dry-run`: случайный `docker run` покажет
смету и выйдет, а не запустит сточасовую задачу. В серверном compose политика перезапуска —
`on-failure`, а не `unless-stopped`: кампания завершается штатно с кодом 0, и «перезапускать
всегда» крутило бы контейнер вхолостую по кругу, тогда как падение или перезагрузку хоста
`on-failure` подхватывает, а `--resume` продолжает с места.
**Главная тонкость — Vulkan внутри контейнера.** NVIDIA Container Toolkit подкладывает **Главная тонкость — Vulkan внутри контейнера.** NVIDIA Container Toolkit подкладывает
Vulkan-ICD (`nvidia_icd.json`) только если в `NVIDIA_DRIVER_CAPABILITIES` есть `graphics`; Vulkan-ICD (`nvidia_icd.json`) только если в `NVIDIA_DRIVER_CAPABILITIES` есть `graphics`;
@@ -510,8 +548,67 @@ Vulkan-ICD (`nvidia_icd.json`) только если в `NVIDIA_DRIVER_CAPABILIT
Проверено локально: образ собирается, кампания внутри него проходит смоук с монтированием Проверено локально: образ собирается, кампания внутри него проходит смоук с монтированием
результатов на хост, физика совпадает с хостовой до последней цифры (ошибка Тейлора–Грина результатов на хост, физика совпадает с хостовой до последней цифры (ошибка Тейлора–Грина
9.829e-3 при N=64 и 2.401e-3 при N=128 — те же значения, что вне контейнера), а `--backend gpu` 9.829e-3 при N=64 и 2.401e-3 при N=128 — те же значения, что вне контейнера), а `--backend gpu`
без проброшенной карты отказывает явным сообщением, а не считает молча. GPU-путь в контейнере без проброшенной карты отказывает явным сообщением, а не считает молча.
проверяется только на машине с картой.
### WSL2 — отдельный путь
В WSL2 всё вышеописанное не работает, и не из-за настроек. **Драйвера Vulkan для Linux у
NVIDIA там нет**: карта отдаётся через `/dev/dxg` по протоколу WDDM, нативный
`libGLX_nvidia` про него не знает и перечисляет ноль устройств. Container Toolkit
подкладывать внутрь нечего, отсюда `could not select device driver "nvidia"`.
Работает другое: **dzn** (Dozen) — драйвер Mesa, транслирующий Vulkan в D3D12, он умеет
говорить с `/dev/dxg` напрямую. Он положен в образ (ради него база сменена с
`debian:bookworm-slim` на `archlinux:base`: в пакетах Mesa у Debian и Ubuntu dzn не
собирают). NVIDIA-runtime для этого пути не нужен вовсе — нужны проброс устройства и
монтирование `/usr/lib/wsl`. Запуск через `docker-compose.wsl.yml`, подробности в
[`bench/README.md`](bench/README.md).
Три вещи, которые надо знать про этот путь.
**wgpu по умолчанию прячет несоответствующие адаптеры.** dzn сообщает о себе
`conformanceVersion = 0.0.0.0`, и wgpu молча его отбрасывает — решатель докладывает, что
GPU не найден. Согласие даётся явно, переменной `WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1`;
чтобы она вообще читалась, в `gpu.rs` при создании инстанса стоит
`InstanceFlags::from_build_config().with_env()`. Поведение по умолчанию не изменилось: без
переменной несоответствующие адаптеры по-прежнему скрыты.
**У dzn мал предел на размер одной привязки — 128 МиБ** против гигабайтов у нативных
драйверов. Массив популяций занимает `nx · ny · 9 · 4` байта, поэтому при одной общей
привязке потолок выходил 3.73 млн узлов, и сетки от 2048×2048 не запускались вовсе. Но
ограничена именно привязка, а не буфер (`max_buffer_size` у dzn 2047 МиБ), так что тот же
буфер теперь показывается **девятью привязками по одному направлению**: потолок
поднимается до 33.5 млн узлов. Вариант выбирается по возможностям адаптера; где предела
нет, собирается прежний общий, без `switch` в аксессорах. Оба дают одинаковые числа
(Cd 2.39486 в обоих), раздельный стоит 5.7% пропускной способности. Принудительно
включается переменной `KBC2D_SPLIT_POPULATIONS=1` — она нужна для сверки двух вариантов
на одной карте.
**Точность трансляция не портит.** Замерено на Intel Iris Xe одним и тем же прогоном
(`bench/parity.py`, цилиндр Re=20, 8000 шагов):
| | Cd | energy_end (Тейлор–Грин) |
|---|---|---|
| CPU, f64 | 2.39490 | 5.74754e-05 |
| нативный Vulkan, f32 | 2.39486 | 5.74813e-05 |
| dzn в контейнере, f32 | 2.39486 | 5.74810e-05 |
Числа dzn и нативного драйвера сходятся до 5–6 значащих цифр, и разница между ними меньше,
чем между любым из них и f64. Считает трансляция то же самое.
**Скорость она портит, но тем меньше, чем крупнее сетка** — плата почти вся приходится на
трансляцию вызова, а не счёта:
| сетка | узлов | нативно | dzn в контейнере | плата |
|---|---|---|---|---|
| 320×192 | 61 тыс. | 188.8 MLUPS | 44.5 MLUPS | 4.2× |
| 960×480 | 461 тыс. | 125.3 MLUPS | 86.0 MLUPS | 1.46× |
| 1920×960 | 1.84 млн | 128.7 MLUPS | 98.7 MLUPS | 1.30× |
Для кампании это решает дело: 95.1% её стоимости приходится на сетки крупнее 600 тыс.
узлов, а на сетки мельче 150 тыс. — 0.0%. Ожидаемое удорожание всей кампании в контейнере —
около трети, а не в разы. Проверяется профилем `calibrate`, который меряет в том числе
1920×960.
## Дальше ## Дальше
+211 -15
View File
@@ -1,28 +1,223 @@
# Валидационная кампания # Валидационная кампания
115 прогонов, ≈90 часов на RTX 4070 Ti. Проверяет решатель по трём независимым линиям: 115 прогонов, ≈90 машинных часов: 108 на GPU (≈87 часов на RTX 4070 Ti) и 7 на CPU (≈3 часа —
исследования сходимости группы A, где нужен f64). Проверяет решатель по трём независимым линиям:
эталонам из статей авторов метода, литературе по обтеканию тел и внутренним инвариантам самой эталонам из статей авторов метода, литературе по обтеканию тел и внутренним инвариантам самой
схемы. Каждый прогон кладёт логи, ряды, машиночитаемую сводку и гифку в собственную папку. схемы. Каждый прогон кладёт в собственную папку `cmd.txt`, `log.txt`, `report.txt`, `series.csv`
и `summary.json`; гифку пишут **59 прогонов из 115** — остальным `--gif` не передаётся.
## Пять долей: чужие ПК под Windows / WSL2
Кампания разложена на **пять долей, равных по времени**: у каждого прогона в `scenarios.json`
есть поле `shard` (1…5). Каждая доля — отдельный контейнер, который при развёртывании **сразу
начинает считать**: ни сборки, ни ручной цепочки проверок, всё окружение (решатель, Vulkan-
загрузчик, dzn, Python) — внутри образа.
На машину переносятся два файла из корня решателя — `docker-compose.shards.yml` и `shard.bat`:
```bat
shard.bat 3
```
или из любой оболочки:
```sh
docker compose -f docker-compose.shards.yml up -d shard3
docker compose -f docker-compose.shards.yml logs -f shard3
```
При старте контейнер (`entrypoint.sh`) сам делает то, что раньше делалось профилями check:
1. выбирает путь к карте — есть `/dev/dxg`, значит WSL2 / Docker Desktop и dzn; нет —
нативный Linux с NVIDIA Container Toolkit;
2. проверяет `vulkaninfo`: нужен хотя бы один не программный адаптер;
3. гоняет `parity.py` (~2 мин) и только при совпадении с CPU-эталоном идёт дальше; после
успеха кладёт `out/parity_shardN.ok`, и перезапуск сверку не повторяет;
4. `run_campaign.sh --resume --shard N`.
Любой провал на шагах 1–3 останавливает контейнер с объяснением, что поправить на хосте, — а
не отдаёт кампанию считать впустую.
**В контейнер нельзя положить** драйвер видеокарты: он должен стоять в самой Windows (Vulkan
внутри контейнера — это dzn, транслирующий вызовы в D3D12 драйвера хоста). И нужен Docker
Desktop с бэкендом WSL2 (так по умолчанию) либо Docker внутри WSL2.
| доля | прогонов | ≈ часов |
|---|---|---|
| 1 | 22 | 24.0 |
| 2 | 23 | 24.0 |
| 3 | 23 | 24.0 |
| 4 | 23 | 24.0 |
| 5 | 24 | 24.0 |
Часы — оценка при 1200 MLUPS на крупной сетке **с поправкой на размер сетки**: на dzn мелкие
сетки считаются медленнее на узел, а сверхмелкие из группы I идут через раздельные привязки
(кривая — `DZN_MLUPS` в `gen_scenarios.py`, по замерам из раздела «Чего трансляция стоит по
скорости»). Раскладка жадная: самый долгий прогон — в самую лёгкую долю. Девять прогонов
группы I (по ≈8.7 ч) разошлись по два на долю в четырёх долях, пятая получила один и добрала остальным.
Равенство долей — равенство на **одинаковых** картах: на разных доли закончатся в разное время.
Абсолютные часы на конкретной карте — `--dry-run --shard N` после `--calibrate`.
Пересобрать раскладку под другое число машин: `python gen_scenarios.py --shards 3`.
**Сбор результатов.** Каждая доля пишет в `./out` рядом с compose-файлом: каталоги прогонов
`out/<id>/` (уникальны по id), свою сводку `summary_shardN.csv` и свой журнал
`campaign_shardN.log`. Собрать кампанию целиком — скопировать все пять `out/` в одну папку.
## Быстрый старт на сервере ## Быстрый старт на сервере
```sh Образ опубликован, собирать ничего не нужно: **`notbigghost/kbc2d:1.3.0`**. Исходники на
docker build -t kbc2d docs/theory/2d_solver сервере тоже не нужны — переносится один файл `docker-compose.server.yml`.
docker run --rm --gpus all --entrypoint python3 kbc2d preflight.py # сценарии стартуют
docker run --rm --gpus all kbc2d --calibrate # GPU виден
docker run -d --gpus all -v "$PWD/out:/work/bench/out" --name kbc2d kbc2d --resume
docker logs -f kbc2d
```
Первым делом стоит убедиться, что Vulkan внутри контейнера действительно видит карту:
```sh ```sh
docker run --rm --gpus all --entrypoint vulkaninfo kbc2d --summary | head -20 mkdir -p ~/kbc2d && cd ~/kbc2d # сюда же ляжет ./out с результатами
# перенести сюда docker-compose.server.yml
C=docker-compose.server.yml
docker compose -f $C --profile check run --rm vulkan # 1. карта видна из контейнера?
docker compose -f $C --profile check run --rm preflight # 2. все 115 сценариев стартуют?
docker compose -f $C --profile check run --rm calibrate # 3. сколько MLUPS на этой машине?
docker compose -f $C --profile check run --rm plan # 4. смета в часах по замеренному
docker compose -f $C up -d # 5. кампания
docker compose -f $C logs -f
``` ```
Если адаптеров ноль — почти наверняка дело в `NVIDIA_DRIVER_CAPABILITIES`: Container Toolkit Порядок не случайный: узнать, что карта не видна, лучше на первом шаге, чем через час счёта.
подкладывает Vulkan-ICD только при наличии `graphics` в списке. В образе это прописано, но
может быть переопределено снаружи. Замеренные калибровкой числа подставляются переменными окружения — они влияют только на
оценки в часах, не на счёт:
```sh
KBC2D_GPU_MLUPS=1450 KBC2D_CPU_MLUPS=40 docker compose -f $C --profile check run --rm plan
```
**Если `vulkaninfo` показывает ноль адаптеров** — почти наверняка дело в
`NVIDIA_DRIVER_CAPABILITIES`: Container Toolkit подкладывает Vulkan-ICD только при наличии
`graphics` в списке. В образе и в compose это прописано, но может быть переопределено снаружи.
Проверить, что хост вообще умеет отдавать карту:
```sh
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi
```
## Запуск в WSL2
Отдельный путь, потому что в WSL2 **драйвера Vulkan для Linux у NVIDIA не существует**.
Карта отдаётся через `/dev/dxg` по протоколу WDDM; нативный `libGLX_nvidia` про него не
знает и возвращает ноль устройств — загрузчик его выбрасывает. Отсюда и `could not select
device driver "nvidia"`: NVIDIA Container Toolkit тут не поможет, потому что подкладывать
внутрь нечего.
Зато с `/dev/dxg` умеет говорить **dzn** (Dozen) — драйвер Mesa, транслирующий Vulkan в
D3D12. Он лежит в образе. NVIDIA-runtime при этом **не нужен вовсе**: достаточно проброса
устройства и монтирования `/usr/lib/wsl`, где Microsoft держит `libd3d12.so`.
```sh
C=docker-compose.wsl.yml
docker compose -f $C --profile check run --rm vulkan # 1. карта видна?
docker compose -f $C --profile check run --rm parity # 2. считает ли она правильно?
docker compose -f $C --profile check run --rm calibrate # 3. и с какой скоростью?
docker compose -f $C --profile check run --rm preflight # 4. все сценарии стартуют?
docker compose -f $C --profile check run --rm plan # 5. смета в часах
docker compose -f $C up -d # 6. кампания
```
Если образа нет ни локально, ни в реестре — `docker compose -f $C build`, доступ к Docker
Hub не обязателен.
### Шаг parity обязателен
dzn сообщает о себе `conformanceVersion = 0.0.0.0`: набор тестов соответствия Vulkan он не
проходил. wgpu по этой причине по умолчанию **прячет** такие адаптеры, и решатель сообщает,
что GPU не найден. Согласие считать на непроверенном драйвере даётся явно — переменной
`WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1`, она прописана в `docker-compose.wsl.yml`.
Раз соответствие не проверено вендором, его проверяем сами. `bench/parity.py` гоняет два
коротких эталона на CPU в f64 и на GPU и сверяет числа: вихрь Тейлора–Грина (есть точное
решение) и стационарное обтекание цилиндра при Re=20. Течения выбраны намеренно не
хаотические — там расхождение f32 и f64 не нарастает, поэтому заметное различие означает
проблему драйвера, а не разрядности.
Замерено на Intel Iris Xe: **dzn совпадает с нативным драйвером той же карты до 5–6
значащих цифр** (`energy_end` 3.02277e-07 против 3.02283e-07, `cd` 2.39486 против 2.39486).
Точность трансляция не портит. Но это замер на Intel; на своей карте прогоните сами — две
минуты.
### Предел 128 МиБ на привязку и как он снят
dzn объявляет `max_storage_buffer_binding_size = 128 МиБ` (2²⁷ байт), тогда как нативные
драйверы дают гигабайты. Массив популяций занимает `nx · ny · 9 · 4` байта, так что при
одной общей привязке потолок выходил **3.73 млн узлов** (примерно 1920×1920), и 14
прогонов кампании из 115 падали на создании bind group:
```
Buffer binding 0 range 150994944 exceeds `max_*_buffer_binding_size` limit 134217728
```
На эти 14 приходилось 70.7% стоимости кампании — дорогие прогоны как раз крупносеточные.
Существенно, что ограничена только **привязка**: `max_buffer_size` у dzn 2047 МиБ, то есть
буфер держать разрешено, нельзя лишь показать шейдеру его целиком. Поэтому решатель теперь
умеет показывать тот же буфер **девятью привязками**, по одному направлению в каждой.
Потолок поднимается в девять раз — до 33.5 млн узлов, чего хватает всей кампании с запасом
(самая крупная сетка в ней 4096×4096 — 16.8 млн узлов).
Вариант выбирается сам, по `max_storage_buffer_binding_size` адаптера: где предела нет,
собирается прежний общий вариант без `switch` в аксессорах. Проверить оба на одной карте
можно переменной `KBC2D_SPLIT_POPULATIONS=1` — она включает раздельные привязки
принудительно.
Замерено на Intel Iris Xe, один драйвер, два варианта привязки:
| | Cd | energy_end | MLUPS (цилиндр) |
|---|---|---|---|
| общая привязка | 2.39486 | 5.74813e-05 | 169.7 |
| девять привязок | 2.39486 | 5.74813e-05 | 160.0 |
Числа совпадают полностью; раздельный вариант стоит **5.7%** пропускной способности, и
включается только там, где без него счёт вообще невозможен.
### Чего трансляция стоит по скорости
Плата есть, но она почти вся — накладные расходы на вызов, а не на счёт, и потому падает
с ростом сетки. Замерено на Intel Iris Xe, один и тот же решатель:
| сетка | узлов | нативный Vulkan | dzn в контейнере | плата |
|---|---|---|---|---|
| 320×192 | 61 тыс. | 188.8 MLUPS | 44.5 MLUPS | **4.2×** |
| 960×480 | 461 тыс. | 125.3 MLUPS | 86.0 MLUPS | **1.46×** |
| 1920×960 | 1.84 млн | 128.7 MLUPS | 98.7 MLUPS | **1.30×** |
| 2048×2048 | 4.19 млн | 111.8 MLUPS | 67.4 MLUPS | **1.66×** |
Последняя строка стоит особняком: там уже раздельные привязки (см. ниже), и в плату
входит их `switch` в аксессорах. Ожидаемый разброс по кампании — от 1.3× до 1.7×.
Каждый шаг решателя — несколько отправок в очередь; на мелкой сетке трансляция вызова
стоит дороже самого счёта, на крупной размазывается. Для кампании это решающее
обстоятельство, потому что дорогие прогоны в ней как раз крупные:
| узлов в сетке | прогонов | доля стоимости кампании |
|---|---|---|
| < 150 тыс. | 18 | 0.0% |
| 150–600 тыс. | 47 | 4.9% |
| > 600 тыс. | 50 | **95.1%** |
То есть 95% времени кампания проводит там, где плата 1.3–1.5×, и почти не бывает там, где
она четырёхкратна. Ожидаемое удорожание по всей кампании — около трети: 90 часов
превращаются примерно в 115–120, а не в 400.
Проверьте на своей карте: профиль `calibrate` меряет три сетки, и ориентироваться надо на
**1920×960** — она представительна для кампании, а 240×120 показывает худший случай.
## Своя сборка образа
Если нужен образ из текущего состояния репозитория, а не опубликованный:
```sh
docker build -t kbc2d docs/theory/2d_solver --build-arg VERSION=dev --build-arg REVISION=$(git rev-parse --short HEAD)
```
Рядом лежит `docker-compose.yml` — то же самое через `build:`, для локальной отладки.
## Без Docker ## Без Docker
@@ -49,6 +244,7 @@ KBC2D_GPU_MLUPS=1400 ./run_campaign.sh --dry-run
| `--group A,B` | только выбранные группы | | `--group A,B` | только выбранные группы |
| `--only cyl_re150` | по подстроке идентификатора | | `--only cyl_re150` | по подстроке идентификатора |
| `--budget-hours 24` | остановиться, когда время выйдет | | `--budget-hours 24` | остановиться, когда время выйдет |
| `--shard 3` | только доля 3 (поле `shard` в `scenarios.json`); то же — переменная `KBC2D_SHARD` |
Прогон, который упал или развалился, помечается в сводке и **не останавливает кампанию**: Прогон, который упал или развалился, помечается в сводке и **не останавливает кампанию**:
группы E и G специально ищут предел устойчивости, там развал — ожидаемый результат. группы E и G специально ищут предел устойчивости, там развал — ожидаемый результат.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Точка входа образа.
#
# Без KBC2D_SHARD — ровно прежнее поведение: аргументы уходят в run_campaign.sh, а по
# умолчанию (CMD --dry-run) печатается смета. Профили check в compose-файлах работают как раньше.
#
# С KBC2D_SHARD=K контейнер — одна из долей кампании, и считать он начинает сам, без ручной
# цепочки vulkan → parity → plan. Перед стартом три шага, каждый обрывает запуск с понятным
# сообщением, а не даёт кампании уйти считать впустую:
# 1. путь к карте: /dev/dxg есть — значит WSL2 или Docker Desktop, и работает dzn;
# нет — настоящий Linux, Vulkan-ICD подкладывает NVIDIA Container Toolkit;
# 2. vulkaninfo должен увидеть хотя бы один НЕпрограммный адаптер;
# 3. parity.py — сверка GPU с CPU-f64 на двух эталонах (~2 мин). Один раз на каталог out:
# после успеха кладётся маркер, и перезапуск контейнера её не повторяет.
set -u
cd "$(dirname "$0")"
[ -n "${KBC2D_SHARD:-}" ] || exec ./run_campaign.sh "$@"
K="$KBC2D_SHARD"
OUT="${KBC2D_OUT:-out}"
mkdir -p "$OUT"
say() { echo "[доля $K] $*"; }
die() { echo; echo "[доля $K] ОСТАНОВЛЕНО: $*" >&2; exit 3; }
# ── 1. путь к карте ──────────────────────────────────────────────────────────
if [ -e /dev/dxg ]; then
say "найден /dev/dxg — WSL2 / Docker Desktop, Vulkan через dzn (Vulkan → D3D12)"
[ -d /usr/lib/wsl/lib ] || die "нет /usr/lib/wsl/lib: смонтируйте /usr/lib/wsl:/usr/lib/wsl:ro
(в docker-compose.shards.yml это уже прописано)"
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-/usr/lib/wsl/lib}"
# dzn сообщает conformanceVersion 0.0.0.0, и без согласия wgpu его прячет. Согласие
# оправдано только потому, что шаг 3 ниже проверяет числа.
export WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER="${WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER:-1}"
else
say "/dev/dxg нет — нативный Linux, Vulkan-драйвер NVIDIA от Container Toolkit"
fi
# ── 2. видна ли карта ────────────────────────────────────────────────────────
info=$(vulkaninfo --summary 2>&1)
gpus=$(printf '%s\n' "$info" | grep -E 'deviceType *=' | grep -vc 'PHYSICAL_DEVICE_TYPE_CPU')
if [ "$gpus" -lt 1 ]; then
printf '%s\n' "$info" | tail -20 >&2
die "Vulkan не видит ни одной видеокарты. Что проверить на хосте:
* Windows / WSL2: драйвер видеокарты под Windows установлен и свежий; Docker Desktop
работает на бэкенде WSL2; контейнеру проброшены /dev/dxg и /usr/lib/wsl.
* Linux: установлен nvidia-container-toolkit, контейнер запущен с GPU, и в
NVIDIA_DRIVER_CAPABILITIES есть graphics (сейчас: ${NVIDIA_DRIVER_CAPABILITIES:-не задано})."
fi
printf '%s\n' "$info" | grep -E 'deviceName *=|driverName *=|deviceType *=' | sed 's/^ */ /'
# ── 3. считает ли карта правильно ────────────────────────────────────────────
mark="$OUT/parity_shard$K.ok"
if [ -f "$mark" ]; then
say "сверка GPU с CPU уже пройдена ранее ($mark)"
else
say "сверка GPU с CPU-эталоном (parity.py, около двух минут)…"
python3 parity.py || die "GPU расходится с CPU-эталоном — результаты этой машины нечем
защищать. Вывод сверки выше. Повторить: удалить контейнер и поднять заново."
date -Is > "$mark"
fi
# ── кампания ─────────────────────────────────────────────────────────────────
say "старт: run_campaign.sh --resume --shard $K"
exec ./run_campaign.sh --resume --shard "$K" "$@"
+59 -3
View File
@@ -5,6 +5,7 @@
# получаются циклами, а стоимость каждого прогона считается тут же и суммируется. Запуск: # получаются циклами, а стоимость каждого прогона считается тут же и суммируется. Запуск:
# python gen_scenarios.py # перезаписать scenarios.json и напечатать смету # python gen_scenarios.py # перезаписать scenarios.json и напечатать смету
# python gen_scenarios.py --scale 2 # растянуть все длительности вдвое # python gen_scenarios.py --scale 2 # растянуть все длительности вдвое
# python gen_scenarios.py --shards 5 # на сколько равных по времени долей делить кампанию
# #
# Стоимость меряется в ОБНОВЛЕНИЯХ УЗЛОВ (nodes_per_step * steps) — единственная переносимая # Стоимость меряется в ОБНОВЛЕНИЯХ УЗЛОВ (nodes_per_step * steps) — единственная переносимая
# между машинами мера. Часы драйвер получает из неё, поделив на фактические MLUPS, которые # между машинами мера. Часы драйвер получает из неё, поделив на фактические MLUPS, которые
@@ -23,6 +24,18 @@ GIF_MAX_W = 1280 # предельная ширина кадра после
GIF_MIN_W = 480 # ниже не опускаемся: нечитаемая гифка бесполезнее большой GIF_MIN_W = 480 # ниже не опускаемся: нечитаемая гифка бесполезнее большой
GIF_PX_BUDGET = 1.5e9 # кадры×пиксели на гифку; при 0.103 байта на пиксель это ~150 МБ GIF_PX_BUDGET = 1.5e9 # кадры×пиксели на гифку; при 0.103 байта на пиксель это ~150 МБ
# Доли кампании: прогоны раскладываются по N контейнерам так, чтобы те считали примерно
# одинаковое ВРЕМЯ, а не одинаковое число узлов. Время на узел зависит от размера сетки:
# на мелкой накладные расходы на отправку в очередь дороже самого счёта. Кривая — замер
# dzn в контейнере (Intel Iris Xe, bench/README.md, «Чего трансляция стоит по скорости»),
# потому что доли рассчитаны на чужие Windows/WSL-машины, где путь к карте — именно dzn.
# Важна только ФОРМА кривой: абсолют нормируется к ASSUMED_GPU_MLUPS.
ASSUMED_GPU_MLUPS = 1200
ASSUMED_CPU_MLUPS = 22
DZN_MLUPS = [(61_440, 44.5), (460_800, 86.0), (1_843_200, 98.7)]
SPLIT_NODES = 3_730_000 # выше — раздельные привязки популяций, у них своя скорость
SPLIT_MLUPS = 67.4 # замер на 2048×2048
def conv_steps(d_cells, n_conv, u_lat=U_LAT): def conv_steps(d_cells, n_conv, u_lat=U_LAT):
"""Сколько шагов нужно на n_conv конвективных времён D/U.""" """Сколько шагов нужно на n_conv конвективных времён D/U."""
@@ -472,15 +485,50 @@ def group_i(scale):
nx * ny, st, gif="fine_multi.gif", nx=nx) nx * ny, st, gif="fine_multi.gif", nx=nx)
def est_hours(r):
"""Оценка времени прогона в часах для раскладки по долям (см. DZN_MLUPS)."""
if r["backend"] == "cpu":
return r["cost"] / (ASSUMED_CPU_MLUPS * 1e6) / 3600
n = r["nodes_per_step"]
if n > SPLIT_NODES:
m = SPLIT_MLUPS
elif n <= DZN_MLUPS[0][0]:
m = DZN_MLUPS[0][1]
elif n >= DZN_MLUPS[-1][0]:
m = DZN_MLUPS[-1][1]
else:
for (n0, m0), (n1, m1) in zip(DZN_MLUPS, DZN_MLUPS[1:]):
if n <= n1:
t = math.log(n / n0) / math.log(n1 / n0)
m = m0 + t * (m1 - m0)
break
rel = m / DZN_MLUPS[-1][1]
return r["cost"] / (ASSUMED_GPU_MLUPS * 1e6 * rel) / 3600
def assign_shards(runs, n):
"""Разложить прогоны по n долям жадно (LPT): самый долгий — в самую лёгкую долю.
Порядок прогонов внутри доли остаётся порядком групп. Возвращает часы по долям."""
load = [0.0] * n
for r in sorted(runs, key=lambda r: (-est_hours(r), r["id"])):
k = min(range(n), key=lambda i: load[i])
r["shard"] = k + 1
load[k] += est_hours(r)
return load
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--scale", type=float, default=2.0, ap.add_argument("--scale", type=float, default=2.0,
help="общий множитель длительности всех прогонов") help="общий множитель длительности всех прогонов")
ap.add_argument("--shards", type=int, default=5,
help="на сколько равных по времени долей делить кампанию")
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "scenarios.json")) ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "scenarios.json"))
args = ap.parse_args() args = ap.parse_args()
for g in (group_a, group_b, group_c, group_d, group_e, group_f, group_g, group_h, group_i): for g in (group_a, group_b, group_c, group_d, group_e, group_f, group_g, group_h, group_i):
g(args.scale) g(args.scale)
shard_hours = assign_shards(RUNS, args.shards)
doc = { doc = {
"meta": { "meta": {
@@ -490,12 +538,14 @@ def main():
"gif_policy": "вся длительность прогона, реальная скорость, 10 кадр/с; кадр " "gif_policy": "вся длительность прогона, реальная скорость, 10 кадр/с; кадр "
"прореживается до ширины не больше 1280 пикселей", "прореживается до ширины не больше 1280 пикселей",
"scale": args.scale, "scale": args.scale,
"assumed_gpu_mlups": 1200, "assumed_gpu_mlups": ASSUMED_GPU_MLUPS,
"assumed_cpu_mlups": 22, "assumed_cpu_mlups": ASSUMED_CPU_MLUPS,
"shards": args.shards,
"shard_hours": [round(h, 2) for h in shard_hours],
}, },
"runs": RUNS, "runs": RUNS,
} }
with open(args.out, "w", encoding="utf-8") as f: with open(args.out, "w", encoding="utf-8", newline="\n") as f:
json.dump(doc, f, ensure_ascii=False, indent=1) json.dump(doc, f, ensure_ascii=False, indent=1)
print(f"прогонов: {len(RUNS)} файл: {args.out}\n") print(f"прогонов: {len(RUNS)} файл: {args.out}\n")
@@ -513,6 +563,12 @@ def main():
f"{tot_gpu:>12.1f}{tot_cpu:>12.1f}") f"{tot_gpu:>12.1f}{tot_cpu:>12.1f}")
print(f"\nвсего ≈ {tot_gpu + tot_cpu:.1f} ч при 1200 MLUPS на GPU и 22 на CPU") print(f"\nвсего ≈ {tot_gpu + tot_cpu:.1f} ч при 1200 MLUPS на GPU и 22 на CPU")
print("\nдоли (часы с поправкой на размер сетки, см. DZN_MLUPS):")
for k, h in enumerate(shard_hours, 1):
n = sum(1 for r in RUNS if r["shard"] == k)
print(f" доля {k}: {n:>3} прогонов ≈ {h:5.1f} ч")
print(f" разброс: {(max(shard_hours) / min(shard_hours) - 1) * 100:.1f} %")
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Сверка GPU-пути с эталонным CPU-путём.
Зачем. На настоящем сервере GPU-путь идёт через драйвер NVIDIA. В WSL такого драйвера
для Linux нет, и единственный доступный Vulkan — dzn (Dozen), транслирующий вызовы в
D3D12. Он честно сообщает о себе `conformanceVersion = 0.0.0.0`, то есть набор тестов
соответствия не проходил. Арифметика f32 в обоих API описана одним и тем же IEEE 754,
но точность трансцендентных функций (exp, log, sqrt — а они в энтропийном равновесии
на каждом узле) стандартами задаётся с допуском в несколько ULP, и допуски эти разные.
Плюс энтропийный стабилизатор сравнивает знаменатель с относительным порогом GREL=1e-8:
достаточно мелкого расхождения, чтобы решение «вырожден или нет» поменялось.
Поэтому пригодность dzn нельзя принимать на веру — её надо мерить. Скрипт гоняет два
коротких эталона на CPU (f64) и на GPU и сверяет числа из summary.json.
Прогоны подобраны намеренно НЕ хаотические: вихрь Тейлора–Грина затухает гладко и имеет
точное решение, обтекание цилиндра при Re=20 стационарно. В таких течениях расхождение
f32 и f64 не нарастает, поэтому любое заметное различие означает проблему драйвера, а не
разрядности. На развитой дорожке (Re >= 100) сверять бессмысленно: там разойдётся любая
пара запусков с разной арифметикой.
Порог взят с запасом от факта: на нативном Vulkan разница Cd между CPU-f64 и GPU-f32
измерена и составила 0.007%.
python3 parity.py # сверка
python3 parity.py --tol 2e-3 # свой порог
python3 parity.py --keep out/ # оставить сводки для разбора
Код возврата 1, если хоть одно поле вышло за порог или прогон развалился.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
BIN = os.environ.get("KBC2D_BIN") or os.path.join(
HERE, "..", "target", "release", "kbc2d" + (".exe" if os.name == "nt" else ""))
# Поля сводки: "fields" сверяются с порогом, "report" только печатаются.
#
# * energy_end, enstrophy_end — интегралы поля, ловят расхождение самого счёта;
# * cd — интегральная сила, накапливает ошибку по всей границе тела;
# * gamma_mean и degenerate_frac — поведение энтропийного стабилизатора, самый чуткий
# индикатор расхождений в exp/log;
# * rho_mean — сохранение массы, обязано совпадать почти побитово.
#
# Пороги откалиброваны по замеру на НАТИВНОМ драйвере Vulkan (Intel Iris Xe, Windows):
# там же, где f64 и f32 сравниваются в отсутствие какой-либо трансляции, отклонения
# составили cd 1.7e-5, rho_mean 1.5e-6, gamma_mean 6.5e-4, energy_end 3.4e-3 (вихрь
# доведён до затухания). Пороги ниже взяты с запасом к этим числам, но заметно ниже
# того, что дал бы по-настоящему сломанный драйвер.
#
# Прогоны намеренно НЕ доводятся до глубокого затухания: когда энергия падает до 1e-7,
# f32 упирается в собственный шум, и сверять становится нечего — это свойство
# разрядности, а не драйвера.
CASES = [
{
"name": "Тейлор-Грин 128x128, 500 шагов",
"args": ["--case", "taylor-green", "--nx", "128", "--ny", "128",
"--refine", "1", "--steps", "500", "--case-every", "100"],
# taylor_green_error намеренно НЕ в списке сверяемых, только в отчётных: это
# разность почти равных величин, и её относительное отклонение f32 от f64
# достигает десятков процентов на любом, в том числе нативном, драйвере.
# Замерено на нативном Vulkan: 0.0116 (f64) против 0.0218 (f32). Сверять по
# ней бессмысленно, а видеть её полезно.
"fields": {"energy_end": 1.0, "enstrophy_end": 1.0, "gamma_mean": 1.0},
"report": ["taylor_green_error"],
},
{
"name": "цилиндр Re=20, стационар, 8000 шагов",
"args": ["--shape", "cylinder", "--size", "16", "--nx", "320", "--ny", "192",
"--re", "20", "--refine", "1", "--steps", "8000"],
"fields": {"cd": 1.0, "rho_mean": 0.05, "gamma_mean": 2.0,
"degenerate_frac": 5.0},
"report": ["strouhal"],
},
]
def run(backend, args, summary_path):
"""Один прогон. Возвращает разобранную сводку либо строку с ошибкой."""
cmd = [BIN] + args + ["--backend", backend, "--verbose", "quiet",
"--report-every", "0", "--summary", summary_path]
proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
return None, "\n".join(tail[-6:]) or f"код возврата {proc.returncode}"
try:
with open(summary_path, encoding="utf-8") as f:
return json.load(f), None
except Exception as e:
return None, f"сводка не читается: {e}"
def deviation(ref, got):
"""Относительное отклонение; для околонулевых величин — абсолютное."""
if abs(ref) > 1e-12:
return abs(got - ref) / abs(ref)
return abs(got - ref)
def main():
ap = argparse.ArgumentParser(description="Сверка GPU-пути с CPU-эталоном")
ap.add_argument("--tol", type=float, default=1e-3,
help="базовый относительный порог (по умолчанию 1e-3)")
ap.add_argument("--keep", metavar="DIR", help="куда положить сводки прогонов")
args = ap.parse_args()
if not os.path.exists(BIN):
print(f"не найден бинарь решателя: {BIN}")
return 1
workdir = args.keep or tempfile.mkdtemp(prefix="kbc2d-parity-")
os.makedirs(workdir, exist_ok=True)
failures = []
speeds = []
for case in CASES:
print(f"\n=== {case['name']} ===")
summaries = {}
for backend in ("cpu", "gpu"):
path = os.path.join(workdir, f"{backend}.json")
data, err = run(backend, case["args"], path)
if err:
print(f" {backend}: ПРОГОН НЕ УДАЛСЯ\n {err}")
failures.append(f"{case['name']}: {backend} не отработал")
break
if data.get("blew_up"):
print(f" {backend}: счёт РАЗВАЛИЛСЯ")
failures.append(f"{case['name']}: {backend} развалился")
break
summaries[backend] = data
if len(summaries) != 2:
continue
cpu, gpu = summaries["cpu"], summaries["gpu"]
m_cpu, m_gpu = cpu.get("mlups", 0.0), gpu.get("mlups", 0.0)
speeds.append((case["name"], m_cpu, m_gpu))
ratio = f", ускорение x{m_gpu / m_cpu:.1f}" if m_cpu > 0 else ""
print(f" скорость: CPU {m_cpu:.1f} MLUPS, GPU {m_gpu:.1f} MLUPS{ratio}")
print(f" {'поле':<22}{'CPU (f64)':>16}{'GPU':>16}{'откл.':>12} порог")
for field in case.get("report", []):
if field in cpu and field in gpu:
ref, got = float(cpu[field]), float(gpu[field])
print(f" {field:<22}{ref:>16.6g}{got:>16.6g}{deviation(ref, got):>11.2e}"
f" (только к сведению)")
for field, mult in case["fields"].items():
if field not in cpu or field not in gpu:
print(f" {field:<22}{'нет в сводке':>16}")
continue
ref, got = float(cpu[field]), float(gpu[field])
dev = deviation(ref, got)
tol = args.tol * mult
mark = "" if dev <= tol else " <-- ВЫШЕ ПОРОГА"
print(f" {field:<22}{ref:>16.6g}{got:>16.6g}{dev:>11.2e} {tol:.1e}{mark}")
if dev > tol:
failures.append(f"{case['name']}: {field} отклонилось на {dev:.2e} "
f"при пороге {tol:.1e}")
print()
if speeds:
print("Скорость по прогонам:")
for name, m_cpu, m_gpu in speeds:
print(f" {name}: CPU {m_cpu:.1f} MLUPS, GPU {m_gpu:.1f} MLUPS")
print()
if failures:
print("СВЕРКА НЕ ПРОЙДЕНА:")
for f in failures:
print(f" * {f}")
print("\nGPU-путь на этом драйвере доверия не заслуживает — кампанию на нём "
"запускать нельзя.")
else:
print("Сверка пройдена: GPU считает то же, что CPU в двойной точности.")
if not args.keep:
shutil.rmtree(workdir, ignore_errors=True)
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+37 -3
View File
@@ -27,6 +27,39 @@ DROP = {"--gif", "--xt", "--case-csv", "--gif-field", "--gif-downsample", "--gif
"--gif-every", "--gif-fps", "--series-every"} "--gif-every", "--gif-fps", "--series-every"}
# Строки, которые ничего не объясняют и только вытесняют полезные.
NOISE = (
"note: run with `RUST_BACKTRACE",
"note: Some details are omitted",
"WARNING: dzn is not a conformant",
"stack backtrace",
"Caused by:",
)
def explain(output):
"""Достать из вывода упавшего прогона то, ради чего его вообще читают.
Раньше бралась последняя строка вывода — а у паники Rust последняя строка это
«note: run with RUST_BACKTRACE=1», то есть ровно ноль сведений о причине.
Полезное лежит либо в собственном сообщении решателя, либо на строке ПОСЛЕ
«panicked at»: там текст самой паники.
"""
out = [l.rstrip() for l in output.splitlines() if l.strip()]
out = [l for l in out if not any(n in l for n in NOISE)]
if not out:
return "(без вывода)"
for l in out:
if l.lstrip().startswith("ОШИБКА"):
return l.strip()
for i, l in enumerate(out):
if "panicked at" in l:
rest = [x.strip() for x in out[i + 1:]]
return " | ".join(rest[:4]) if rest else l.strip()
return out[-1].strip()
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--group", help="только эта группа, например I") ap.add_argument("--group", help="только эта группа, например I")
@@ -64,14 +97,15 @@ def main():
ok = p.returncode == 0 ok = p.returncode == 0
print("." if ok else "X", end="", flush=True) print("." if ok else "X", end="", flush=True)
if not ok: if not ok:
tail = (p.stdout + p.stderr).decode("utf-8", "replace").strip().splitlines() out = (p.stdout + p.stderr).decode("utf-8", "replace")
bad.append((r["id"], tail[-1] if tail else "(без вывода)")) bad.append((r["id"], explain(out)))
if i % 40 == 0: if i % 40 == 0:
print(f" {i}/{len(runs)}", flush=True) print(f" {i}/{len(runs)}", flush=True)
print(f"\n\nпроверено сценариев: {len(runs)}, не запустились: {len(bad)}") print(f"\n\nпроверено сценариев: {len(runs)}, не запустились: {len(bad)}")
for id_, msg in bad: for id_, msg in bad:
print(f" {id_:<28} {msg}") print(f" {id_}")
print(f" {msg}")
return 1 if bad else 0 return 1 if bad else 0
+11 -1
View File
@@ -7,6 +7,7 @@
# .\run_campaign.ps1 -Smoke # .\run_campaign.ps1 -Smoke
# .\run_campaign.ps1 -Resume -Group A,B # .\run_campaign.ps1 -Resume -Group A,B
# .\run_campaign.ps1 -Only cyl_re150 -BudgetHours 6 # .\run_campaign.ps1 -Only cyl_re150 -BudgetHours 6
# .\run_campaign.ps1 -Resume -Shard 3
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -16,6 +17,7 @@ param(
[switch]$Smoke, [switch]$Smoke,
[string[]]$Group, [string[]]$Group,
[string]$Only, [string]$Only,
[int]$Shard = 0,
[double]$BudgetHours = 0, [double]$BudgetHours = 0,
[string]$Bin = "..\target\release\kbc2d.exe", [string]$Bin = "..\target\release\kbc2d.exe",
[string]$Scenarios = "scenarios.json", [string]$Scenarios = "scenarios.json",
@@ -30,7 +32,9 @@ if (-not (Test-Path $Scenarios)) { throw "не найден список сце
if ($Calibrate) { if ($Calibrate) {
"Калибровка на этой машине (три коротких прогона)…" "Калибровка на этой машине (три коротких прогона)…"
foreach ($c in @(@(240,120,'gpu'), @(960,480,'gpu'), @(480,240,'cpu'))) { # 1920x960 добавлена не для красоты: на 95% стоимости кампании сетки крупнее
# 600 тыс. узлов, и оценивать по мелким - значит занижать пропускную способность.
foreach ($c in @(@(240,120,'gpu'), @(960,480,'gpu'), @(1920,960,'gpu'), @(480,240,'cpu'))) {
$o = & $Bin --nx $c[0] --ny $c[1] --size 16 --refine 1 --steps 3000 ` $o = & $Bin --nx $c[0] --ny $c[1] --size 16 --refine 1 --steps 3000 `
--report-every 3000 --verbose full --backend $c[2] 2>$null --report-every 3000 --verbose full --backend $c[2] 2>$null
$m = ($o | Select-String 'MLUPS' | Select-Object -Last 1) $m = ($o | Select-String 'MLUPS' | Select-Object -Last 1)
@@ -43,6 +47,7 @@ if ($Calibrate) {
$runs = (Get-Content $Scenarios -Raw -Encoding UTF8 | ConvertFrom-Json).runs $runs = (Get-Content $Scenarios -Raw -Encoding UTF8 | ConvertFrom-Json).runs
if ($Group) { $g = $Group | ForEach-Object { $_.ToUpper() }; $runs = $runs | Where-Object { $g -contains $_.group.ToUpper() } } if ($Group) { $g = $Group | ForEach-Object { $_.ToUpper() }; $runs = $runs | Where-Object { $g -contains $_.group.ToUpper() } }
if ($Shard) { $runs = $runs | Where-Object { $_.shard -eq $Shard } }
if ($Only) { $runs = $runs | Where-Object { $_.id -like "*$Only*" } } if ($Only) { $runs = $runs | Where-Object { $_.id -like "*$Only*" } }
if ($Smoke) { $runs = $runs | Sort-Object cost | Select-Object -First 3 } if ($Smoke) { $runs = $runs | Sort-Object cost | Select-Object -First 3 }
if (-not $runs) { "под выборку не попал ни один прогон"; return } if (-not $runs) { "под выборку не попал ни один прогон"; return }
@@ -65,6 +70,11 @@ if ($DryRun) {
New-Item -ItemType Directory -Force $Out | Out-Null New-Item -ItemType Directory -Force $Out | Out-Null
$summary = Join-Path $Out 'summary.csv' $summary = Join-Path $Out 'summary.csv'
$log = Join-Path $Out 'campaign.log' $log = Join-Path $Out 'campaign.log'
# у каждой доли своя сводка и свой журнал, как в run_campaign.sh
if ($Shard) {
$summary = Join-Path $Out "summary_shard$Shard.csv"
$log = Join-Path $Out "campaign_shard$Shard.log"
}
if (-not (Test-Path $summary)) { 'id,group,backend,status,seconds,steps,cost,title' | Out-File $summary -Encoding utf8 } if (-not (Test-Path $summary)) { 'id,group,backend,status,seconds,steps,cost,title' | Out-File $summary -Encoding utf8 }
$started = Get-Date $started = Get-Date
+17 -3
View File
@@ -9,6 +9,7 @@
# ./run_campaign.sh --group A,B только выбранные группы # ./run_campaign.sh --group A,B только выбранные группы
# ./run_campaign.sh --only cyl_re150 по подстроке идентификатора # ./run_campaign.sh --only cyl_re150 по подстроке идентификатора
# ./run_campaign.sh --budget-hours 24 остановиться, когда время выйдет # ./run_campaign.sh --budget-hours 24 остановиться, когда время выйдет
# ./run_campaign.sh --shard 3 только доля 3 из scenarios.json (или KBC2D_SHARD=3)
# #
# Прогон, который упал или развалился, помечается в сводке и НЕ останавливает кампанию: # Прогон, который упал или развалился, помечается в сводке и НЕ останавливает кампанию:
# группы E и G специально ищут предел устойчивости. # группы E и G специально ищут предел устойчивости.
@@ -23,6 +24,7 @@ OUT="${KBC2D_OUT:-out}"
# молча игнорируется, а "$GROUPS" под root разворачивается в 0 — и выборка съедает всю # молча игнорируется, а "$GROUPS" под root разворачивается в 0 — и выборка съедает всю
# кампанию, не сказав ни слова. Отсюда GRP_SEL. # кампанию, не сказав ни слова. Отсюда GRP_SEL.
DRY=0; RESUME=0; CALIB=0; SMOKE=0; GRP_SEL=""; ONLY=""; BUDGET="" DRY=0; RESUME=0; CALIB=0; SMOKE=0; GRP_SEL=""; ONLY=""; BUDGET=""
SHARD="${KBC2D_SHARD:-}"
GPU_MLUPS="${KBC2D_GPU_MLUPS:-1200}" GPU_MLUPS="${KBC2D_GPU_MLUPS:-1200}"
CPU_MLUPS="${KBC2D_CPU_MLUPS:-22}" CPU_MLUPS="${KBC2D_CPU_MLUPS:-22}"
@@ -35,7 +37,8 @@ while [ $# -gt 0 ]; do
--group) GRP_SEL="$2"; shift ;; --group) GRP_SEL="$2"; shift ;;
--only) ONLY="$2"; shift ;; --only) ONLY="$2"; shift ;;
--budget-hours) BUDGET="$2"; shift ;; --budget-hours) BUDGET="$2"; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;; --shard) SHARD="$2"; shift ;;
-h|--help) sed -n '2,21p' "$0"; exit 0 ;;
*) echo "неизвестный ключ: $1" >&2; exit 2 ;; *) echo "неизвестный ключ: $1" >&2; exit 2 ;;
esac esac
shift shift
@@ -48,7 +51,9 @@ command -v python3 >/dev/null 2>&1 && PY=python3 || PY=python
# ── калибровка: три коротких прогона на месте, чтобы оценки в часах были не гаданием ── # ── калибровка: три коротких прогона на месте, чтобы оценки в часах были не гаданием ──
if [ "$CALIB" = 1 ]; then if [ "$CALIB" = 1 ]; then
echo "Калибровка на этой машине (три коротких прогона)…" echo "Калибровка на этой машине (три коротких прогона)…"
for spec in "240 120 gpu" "960 480 gpu" "480 240 cpu"; do # 1920x960 добавлена не для красоты: на 95% стоимости кампании сетки крупнее
# 600 тыс. узлов, и оценивать по мелким — значит занижать пропускную способность.
for spec in "240 120 gpu" "960 480 gpu" "1920 960 gpu" "480 240 cpu"; do
set -- $spec set -- $spec
m=$("$BIN" --nx "$1" --ny "$2" --size 16 --refine 1 --steps 3000 --report-every 3000 \ m=$("$BIN" --nx "$1" --ny "$2" --size 16 --refine 1 --steps 3000 --report-every 3000 \
--verbose full --backend "$3" 2>/dev/null | grep -o '[0-9.]* MLUPS' | tail -1) --verbose full --backend "$3" 2>/dev/null | grep -o '[0-9.]* MLUPS' | tail -1)
@@ -63,13 +68,22 @@ fi
mkdir -p "$OUT" mkdir -p "$OUT"
SUMMARY="$OUT/summary.csv" SUMMARY="$OUT/summary.csv"
LOG="$OUT/campaign.log" LOG="$OUT/campaign.log"
# У каждой доли своя сводка и свой журнал: папки out/ с разных машин сливаются простым
# копированием (каталоги прогонов уникальны по id), а доли на одной машине не пишут в один файл.
if [ -n "$SHARD" ]; then
SUMMARY="$OUT/summary_shard$SHARD.csv"
LOG="$OUT/campaign_shard$SHARD.log"
fi
[ -f "$SUMMARY" ] || echo "id,group,backend,status,seconds,steps,cost,title" > "$SUMMARY" [ -f "$SUMMARY" ] || echo "id,group,backend,status,seconds,steps,cost,title" > "$SUMMARY"
# Выборку и порядок считает python: разбирать JSON башем — верный способ ошибиться. # Выборку и порядок считает python: разбирать JSON башем — верный способ ошибиться.
PLAN=$("$PY" - "$SCEN" "$GRP_SEL" "$ONLY" "$SMOKE" <<'PYEOF' PLAN=$("$PY" - "$SCEN" "$GRP_SEL" "$ONLY" "$SMOKE" "$SHARD" <<'PYEOF'
import json, sys import json, sys
scen, groups, only, smoke = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1" scen, groups, only, smoke = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1"
shard = sys.argv[5]
runs = json.load(open(scen, encoding="utf-8"))["runs"] runs = json.load(open(scen, encoding="utf-8"))["runs"]
if shard:
runs = [r for r in runs if str(r.get("shard")) == shard]
if groups: if groups:
keep = {g.strip().upper() for g in groups.split(",")} keep = {g.strip().upper() for g in groups.split(",")}
runs = [r for r in runs if r["group"].upper() in keep] runs = [r for r in runs if r["group"].upper() in keep]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
# Развёртывание кампании на сервере с NVIDIA. Этот файл САМОДОСТАТОЧЕН: он тянет готовый образ
# из реестра и исходников репозитория не требует. Скопировать на сервер достаточно его одного.
#
# mkdir -p ~/kbc2d && cd ~/kbc2d
# curl -O <ссылка на этот файл> # либо просто перенести файл руками
#
# docker compose -f docker-compose.server.yml --profile check run --rm vulkan
# docker compose -f docker-compose.server.yml --profile check run --rm preflight
# docker compose -f docker-compose.server.yml --profile check run --rm calibrate
# docker compose -f docker-compose.server.yml --profile check run --rm plan
# docker compose -f docker-compose.server.yml up -d
# docker compose -f docker-compose.server.yml logs -f
#
# Порядок именно такой. Сначала vulkan: если карта не видна, кампания молча уйдёт считать
# ничего — точнее, откажется стартовать на первом же прогоне, но узнать об этом через час
# обиднее, чем через минуту. Потом preflight (каждый сценарий стартует на два шага), потом
# calibrate (замер MLUPS этой машины), и только потом сама кампания.
#
# Результаты складываются в ./out на хосте — около 4.4 ГБ гифок и рядов за полный проход.
name: kbc2d
# ── общая часть всех сервисов ────────────────────────────────────────────────
x-kbc2d: &kbc2d
image: notbigghost/kbc2d:1.3.0
pull_policy: missing
volumes:
- ./out:/work/bench/out
environment:
# ГЛАВНОЕ МЕСТО ВСЕГО ФАЙЛА. NVIDIA Container Toolkit подкладывает внутрь Vulkan-ICD
# (nvidia_icd.json) только если в этом списке есть `graphics`. С одним `compute` wgpu не
# увидит НИ ОДНОГО адаптера, и решатель откажется стартовать с --backend gpu. В образе
# значение уже прописано, здесь оно продублировано явно — чтобы его было видно тому, кто
# читает compose, а не Dockerfile.
NVIDIA_DRIVER_CAPABILITIES: compute,utility,graphics
NVIDIA_VISIBLE_DEVICES: all
# Оценки в часах драйвер считает из этих чисел. Умолчания взяты с другой машины —
# подставьте сюда то, что напечатает профиль calibrate.
KBC2D_GPU_MLUPS: ${KBC2D_GPU_MLUPS:-1200}
KBC2D_CPU_MLUPS: ${KBC2D_CPU_MLUPS:-22}
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
services:
# ── сама кампания: единственный сервис, который поднимается по `up -d` ──────
campaign:
<<: *kbc2d
container_name: kbc2d-campaign
# --resume пропускает всё, у чего уже есть summary.json: перезапуск продолжает с места,
# а не начинает заново. Именно поэтому перезапуск здесь безопасен и дёшев.
command: ["--resume"]
# on-failure, а НЕ unless-stopped: кампания завершается штатно с кодом 0, и политика
# «перезапускать всегда» после её окончания крутила бы контейнер вхолостую по кругу.
# Падение (OOM, перезагрузка хоста) даёт ненулевой код и будет подхвачено.
restart: on-failure:5
stop_grace_period: 30s
# Девяносто часов вывода — это сотни мегабайт журнала; без ограничения он съест диск.
# Полные логи каждого прогона всё равно лежат в out/<id>/log.txt.
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
# ── проверки перед запуском (профиль check, сами не поднимаются) ────────────
vulkan:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["vulkaninfo"]
command: ["--summary"]
preflight:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["python3"]
command: ["preflight.py"]
calibrate:
<<: *kbc2d
profiles: ["check"]
command: ["--calibrate"]
plan:
<<: *kbc2d
profiles: ["check"]
command: ["--dry-run"]
# ── если результаты нужны не под root ────────────────────────────────────────
# Контейнер работает от root, поэтому файлы в ./out окажутся root:root. Чтобы они
# принадлежали вам, добавьте в x-kbc2d строку
# user: "${UID}:${GID}"
# и запускайте как UID=$(id -u) GID=$(id -g) docker compose -f … up -d
# (каталог ./out при этом должен быть создан заранее и принадлежать вам).
@@ -0,0 +1,77 @@
# Кампания, разделённая на ПЯТЬ равных по времени долей — по одной на машину.
#
# Рассчитан на чужой ПК с видеокартой под Windows (Docker Desktop) или WSL2. Файл
# САМОДОСТАТОЧЕН: тянет готовый образ из реестра, исходники не нужны. На машину переносятся
# два файла — этот и shard.bat — и запускается одна команда:
#
# shard.bat 3 # Windows, двойной клик не годится: нужен номер
# docker compose -f docker-compose.shards.yml up -d shard3 # то же вручную, из любой оболочки
# docker compose -f docker-compose.shards.yml logs -f shard3 # смотреть ход
#
# Контейнер стартует и сразу считает: сам проверяет, видна ли карта, сверяет её с CPU-эталоном
# (parity.py, ~2 мин) и идёт по своей доле. Если что-то не так — останавливается с объяснением.
#
# Что ДОЛЖНО стоять на машине (в контейнер это положить нельзя):
# * драйвер видеокарты под Windows — Vulkan внутри контейнера транслируется в D3D12 (dzn);
# * Docker Desktop с бэкендом WSL2 (по умолчанию так и есть) либо Docker внутри WSL2.
#
# Доли посчитаны так, чтобы на ОДИНАКОВЫХ картах закончиться одновременно (≈24 ч каждая при
# 1200 MLUPS, с поправкой на размер сетки — см. bench/gen_scenarios.py). На разных картах
# закончатся в разное время. Результаты — в ./out рядом с этим файлом; собрать их с пяти
# машин = скопировать все out/ в одну папку (каталоги прогонов не пересекаются, у каждой доли
# своя сводка summary_shardN.csv).
#
# Перезапуск безопасен: --resume пропускает всё, у чего уже есть summary.json.
name: kbc2d
x-kbc2d: &kbc2d
image: notbigghost/kbc2d:1.3.0
# Если образа нет ни локально, ни в реестре — `docker compose -f docker-compose.shards.yml
# build` соберёт его из каталога с этим файлом (нужны исходники).
build:
context: .
args:
VERSION: "1.3.0"
pull_policy: missing
devices:
# сама видеокарта (WDDM); есть в любом WSL2, в том числе внутри Docker Desktop
- /dev/dxg:/dev/dxg
volumes:
- ./out:/work/bench/out
# libd3d12.so и libdxcore.so, которые нужны dzn; каталог наполняет сам WSL
- /usr/lib/wsl:/usr/lib/wsl:ro
# Доля задаётся переменной KBC2D_SHARD у каждого сервиса. Команда указана явно только затем,
# чтобы не унаследовать от образа CMD --dry-run.
command: ["--resume"]
# on-failure, а НЕ unless-stopped: доля завершается штатно с кодом 0, и «перезапускать
# всегда» гоняло бы контейнер по кругу. Падение (OOM, сбой драйвера) будет подхвачено.
restart: on-failure:5
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
services:
shard1:
<<: *kbc2d
container_name: kbc2d-shard1
environment: { KBC2D_SHARD: "1" }
shard2:
<<: *kbc2d
container_name: kbc2d-shard2
environment: { KBC2D_SHARD: "2" }
shard3:
<<: *kbc2d
container_name: kbc2d-shard3
environment: { KBC2D_SHARD: "3" }
shard4:
<<: *kbc2d
container_name: kbc2d-shard4
environment: { KBC2D_SHARD: "4" }
shard5:
<<: *kbc2d
container_name: kbc2d-shard5
environment: { KBC2D_SHARD: "5" }
@@ -0,0 +1,114 @@
# Запуск решателя и кампании в WSL2 — там, где драйвера NVIDIA для Linux не существует.
#
# ЧЕМ ЭТОТ ФАЙЛ ОТЛИЧАЕТСЯ ОТ docker-compose.server.yml. Тот рассчитан на настоящий
# Linux-сервер: просит у Docker устройство через драйвер `nvidia`, а Vulkan-ICD внутрь
# подкладывает NVIDIA Container Toolkit. В WSL2 этого сделать нельзя в принципе —
# у NVIDIA там нет Linux-библиотеки Vulkan, карта отдаётся по протоколу WDDM через
# /dev/dxg. Отсюда и ошибка `could not select device driver "nvidia"`.
#
# Здесь NVIDIA-runtime не запрашивается ВООБЩЕ. Нужно ровно две вещи:
# * проброс /dev/dxg — сама видеокарта;
# * монтирование /usr/lib/wsl — там Microsoft держит libd3d12.so и libdxcore.so.
# Дальше работает dzn (Dozen) из образа: драйвер Mesa, транслирующий Vulkan в D3D12.
# Ни nvidia-container-toolkit, ни nvidia-ctk, ни правки в daemon.json не требуются.
#
# ВАЖНО, ПРОЧТИТЕ ДО ЗАПУСКА КАМПАНИИ. dzn сообщает о себе conformanceVersion 0.0.0.0,
# то есть набор тестов соответствия Vulkan не проходил. Поэтому порядок такой:
#
# docker compose -f docker-compose.wsl.yml --profile check run --rm vulkan # карта видна?
# docker compose -f docker-compose.wsl.yml --profile check run --rm parity # числа те же?
# docker compose -f docker-compose.wsl.yml --profile check run --rm calibrate # скорость какая?
# docker compose -f docker-compose.wsl.yml --profile check run --rm plan
# docker compose -f docker-compose.wsl.yml up -d
# docker compose -f docker-compose.wsl.yml logs -f
#
# Шаг parity — не формальность. Он гоняет вихрь Тейлора-Грина (есть точное решение) и
# стационарное обтекание цилиндра на CPU в f64 и на GPU, и сверяет числа. Если он не
# прошёл, кампанию запускать нельзя: результаты будет нечем защищать.
#
# Результаты складываются в ./out на хосте.
name: kbc2d
# ── общая часть всех сервисов ────────────────────────────────────────────────
x-kbc2d: &kbc2d
image: notbigghost/kbc2d:1.3.0
# Контекст сборки — каталог с этим файлом. Если образа нет ни локально, ни в реестре,
# достаточно `docker compose -f docker-compose.wsl.yml build`: доступ к Docker Hub
# для запуска не обязателен.
build:
context: .
args:
VERSION: "1.3.0"
pull_policy: missing
devices:
- /dev/dxg:/dev/dxg
volumes:
- ./out:/work/bench/out
# Только на чтение: контейнеру нужны отсюда libd3d12.so, libd3d12core.so и
# libdxcore.so. Каталог наполняет сам WSL из C:\Windows\System32\lxss\lib.
- /usr/lib/wsl:/usr/lib/wsl:ro
environment:
# Без этого dzn не найдёт D3D12 и молча не даст ни одного адаптера.
LD_LIBRARY_PATH: /usr/lib/wsl/lib
# Осознанное согласие считать на драйвере, не прошедшем тесты соответствия Vulkan.
# Без этой переменной wgpu прячет dzn и решатель сообщает, что адаптер не найден.
# Прежде чем запускать кампанию, обязательно пройдите профиль parity.
WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER: "1"
# Оценки в часах драйвер считает из этих чисел. Умолчания взяты с другой машины —
# подставьте сюда то, что напечатает профиль calibrate.
KBC2D_GPU_MLUPS: ${KBC2D_GPU_MLUPS:-1200}
KBC2D_CPU_MLUPS: ${KBC2D_CPU_MLUPS:-22}
services:
# ── сама кампания: единственный сервис, который поднимается по `up -d` ──────
campaign:
<<: *kbc2d
container_name: kbc2d-campaign
# --resume пропускает всё, у чего уже есть summary.json: перезапуск продолжает с
# места, а не начинает заново.
command: ["--resume"]
# on-failure, а НЕ unless-stopped: кампания завершается штатно с кодом 0, и политика
# «перезапускать всегда» после её окончания крутила бы контейнер вхолостую.
restart: on-failure:5
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
# ── проверки перед запуском (профиль check, сами не поднимаются) ────────────
vulkan:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["vulkaninfo"]
command: ["--summary"]
parity:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["python3"]
command: ["parity.py"]
preflight:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["python3"]
command: ["preflight.py"]
calibrate:
<<: *kbc2d
profiles: ["check"]
command: ["--calibrate"]
plan:
<<: *kbc2d
profiles: ["check"]
command: ["--dry-run"]
# ── если результаты нужны не под root ────────────────────────────────────────
# Контейнер работает от root, поэтому файлы в ./out окажутся root:root. Чтобы они
# принадлежали вам, добавьте в x-kbc2d строку
# user: "${UID}:${GID}"
# и запускайте как UID=$(id -u) GID=$(id -g) docker compose -f ... up -d
# (каталог ./out при этом должен быть создан заранее и принадлежать вам).
+39
View File
@@ -0,0 +1,39 @@
@echo off
rem Запуск одной доли кампании kbc2d на этой машине: shard.bat 3
rem Нужны: драйвер видеокарты и Docker Desktop (бэкенд WSL2). Рядом должен лежать
rem docker-compose.shards.yml. Результаты появятся в папке out рядом с этим файлом.
chcp 65001 >nul
setlocal
cd /d "%~dp0"
set "N=%~1"
if "%N%"=="" goto usage
if "%N%"=="1" goto ok
if "%N%"=="2" goto ok
if "%N%"=="3" goto ok
if "%N%"=="4" goto ok
if "%N%"=="5" goto ok
:usage
echo Использование: shard.bat ^<номер доли 1..5^>
echo shard.bat 3 поднять долю 3 и смотреть её журнал
echo Остановить: docker compose -f docker-compose.shards.yml stop
exit /b 2
:ok
if not exist docker-compose.shards.yml (
echo Рядом с shard.bat нет docker-compose.shards.yml.
exit /b 1
)
docker info >nul 2>&1
if errorlevel 1 (
echo Docker не отвечает. Запустите Docker Desktop, дождитесь зелёного статуса и повторите.
exit /b 1
)
docker compose -f docker-compose.shards.yml up -d shard%N%
if errorlevel 1 exit /b 1
echo.
echo Доля %N% запущена и считает в фоне. Журнал ниже; Ctrl+C закрывает только журнал,
echo счёт продолжается. Результаты: %CD%\out
echo.
docker compose -f docker-compose.shards.yml logs -f shard%N%
+280 -70
View File
@@ -51,7 +51,9 @@ struct LevelParams {
flags: u32, flags: u32,
/// число граничных узлов (для моментных моделей стенки) /// число граничных узлов (для моментных моделей стенки)
nwall: u32, nwall: u32,
_pad: [u32; 3], /// шаг между направлениями в буфере популяций, во флоатах (см. dir_stride)
stride: u32,
_pad: [u32; 2],
} }
#[repr(C)] #[repr(C)]
@@ -85,8 +87,9 @@ struct Substep {
struct AmrParams { struct AmrParams {
nghost: u32, nghost: u32,
nrestrict: u32, nrestrict: u32,
ccount: u32, /// шаги между направлениями в буферах популяций грубого и мелкого уровней
fcount: u32, cstride: u32,
fstride: u32,
r01: f32, r01: f32,
rfc: f32, rfc: f32,
w: f32, w: f32,
@@ -145,23 +148,22 @@ struct Results {
const SHADER: &str = r#" const SHADER: &str = r#"
// ───── структуры (обязаны совпадать с gpu.rs) ───── // ───── структуры (обязаны совпадать с gpu.rs) ─────
struct LevelParams { n:u32, nx:u32, ny:u32, nlinks:u32, bcx:f32, bcy:f32, probe_node:u32, flags:u32, nwall:u32, lp1:u32, lp2:u32, lp3:u32 }; struct LevelParams { n:u32, nx:u32, ny:u32, nlinks:u32, bcx:f32, bcy:f32, probe_node:u32, flags:u32, nwall:u32, stride:u32, lp2:u32, lp3:u32 };
// wall_mode: 0 — Bouzidi, 1 — Град, 2 — HRR, 3 — простой отскок // wall_mode: 0 — Bouzidi, 1 — Град, 2 — HRR, 3 — простой отскок
struct Dyn { ux_in:f32, uy_in:f32, rho_out:f32, kbc_model:u32, outlet_extrap:u32, nparts:u32, refine:u32, collision:u32, slot:u32, wall_mode:u32, dp2:u32, dp3:u32 }; struct Dyn { ux_in:f32, uy_in:f32, rho_out:f32, kbc_model:u32, outlet_extrap:u32, nparts:u32, refine:u32, collision:u32, slot:u32, wall_mode:u32, dp2:u32, dp3:u32 };
struct Substep { idx:u32, p0:u32, p1:u32, p2:u32 }; struct Substep { idx:u32, p0:u32, p1:u32, p2:u32 };
struct AmrParams { nghost:u32, nrestrict:u32, ccount:u32, fcount:u32, r01:f32, rfc:f32, w:f32, pad:f32 }; struct AmrParams { nghost:u32, nrestrict:u32, cstride:u32, fstride:u32, r01:f32, rfc:f32, w:f32, pad:f32 };
struct GLink { node:u32, far:u32, i:u32, ib:u32, kind:u32, q:f32, body:u32, p1:u32 }; struct GLink { node:u32, far:u32, i:u32, ib:u32, kind:u32, q:f32, body:u32, p1:u32 };
struct GGhost { fine:u32, c00:u32, c10:u32, c01:u32, c11:u32, tx:f32, ty:f32, p0:u32 }; struct GGhost { fine:u32, c00:u32, c10:u32, c01:u32, c11:u32, tx:f32, ty:f32, p0:u32 };
struct Partial { rho:f32, maxu:f32, gsum:f32, gmin:f32, gmax:f32, cnt:f32, degen:f32, xineg:f32 }; struct Partial { rho:f32, maxu:f32, gsum:f32, gmin:f32, gmax:f32, cnt:f32, degen:f32, xineg:f32 };
@group(0) @binding(0) var<storage, read_write> f : array<f32>;
@group(0) @binding(1) var<storage, read_write> post : array<f32>;
@group(0) @binding(2) var<storage, read_write> gam : array<f32>; @group(0) @binding(2) var<storage, read_write> gam : array<f32>;
@group(0) @binding(3) var<storage, read> solid: array<u32>; @group(0) @binding(3) var<storage, read> solid: array<u32>;
@group(0) @binding(4) var<storage, read> links: array<GLink>; @group(0) @binding(4) var<storage, read> links: array<GLink>;
@group(0) @binding(5) var<storage, read> beta : array<f32>; @group(0) @binding(5) var<storage, read> beta : array<f32>;
@group(0) @binding(6) var<storage, read_write> parts: array<Partial>; @group(0) @binding(6) var<storage, read_write> parts: array<Partial>;
@group(0) @binding(7) var<uniform> P : LevelParams; @group(0) @binding(7) var<uniform> P : LevelParams;
//__POPULATIONS__
@group(1) @binding(0) var<uniform> D : Dyn; @group(1) @binding(0) var<uniform> D : Dyn;
@group(1) @binding(1) var<uniform> S : Substep; @group(1) @binding(1) var<uniform> S : Substep;
@@ -242,8 +244,8 @@ fn shift9(d: array<f32,9>, model: u32) -> array<f32,9> {
return s; return s;
} }
fn load9(base: ptr<function, array<f32,9>>, off: u32, n: u32) { fn load9(base: ptr<function, array<f32,9>>, off: u32) {
for (var i = 0u; i < 9u; i = i + 1u) { (*base)[i] = f[i*n + off]; } for (var i = 0u; i < 9u; i = i + 1u) { (*base)[i] = fget(i, off); }
} }
// Возвращает (пост-столкновительные популяции, gamma, признак вырождения) // Возвращает (пост-столкновительные популяции, gamma, признак вырождения)
@@ -324,15 +326,15 @@ fn k_collide(@builtin(global_invocation_id) gid: vec3<u32>,
let nd = lin(gid, nwg); let nd = lin(gid, nwg);
if (nd >= P.n) { return; } if (nd >= P.n) { return; }
var fv: array<f32,9>; var fv: array<f32,9>;
load9(&fv, nd, P.n); load9(&fv, nd);
if (solid[nd] != 0u) { if (solid[nd] != 0u) {
// внутри тела не считаем: популяции там фиктивны, Bouzidi всё равно их перекрывает // внутри тела не считаем: популяции там фиктивны, Bouzidi всё равно их перекрывает
for (var i = 0u; i < 9u; i = i + 1u) { post[i*P.n + nd] = fv[i]; } for (var i = 0u; i < 9u; i = i + 1u) { pset(i, nd, fv[i]); }
gam[nd] = 2.0; gam[nd] = 2.0;
return; return;
} }
var r = collide9(fv, beta[nd % P.nx], D.collision, D.kbc_model); var r = collide9(fv, beta[nd % P.nx], D.collision, D.kbc_model);
for (var i = 0u; i < 9u; i = i + 1u) { post[i*P.n + nd] = r.fv[i]; } for (var i = 0u; i < 9u; i = i + 1u) { pset(i, nd, r.fv[i]); }
gam[nd] = r.gamma; gam[nd] = r.gamma;
} }
@@ -350,7 +352,7 @@ fn k_stream(@builtin(global_invocation_id) gid: vec3<u32>,
for (var i = 0u; i < 9u; i = i + 1u) { for (var i = 0u; i < 9u; i = i + 1u) {
var xs = (x - cx[i]) % nxi; if (xs < 0) { xs = xs + nxi; } var xs = (x - cx[i]) % nxi; if (xs < 0) { xs = xs + nxi; }
var ys = (y - cy[i]) % nyi; if (ys < 0) { ys = ys + nyi; } var ys = (y - cy[i]) % nyi; if (ys < 0) { ys = ys + nyi; }
f[i*P.n + nd] = post[i*P.n + u32(ys*nxi + xs)]; fset(i, nd, pget(i, u32(ys*nxi + xs)));
} }
} }
@@ -360,20 +362,20 @@ fn k_bouzidi(@builtin(global_invocation_id) gid: vec3<u32>,
let k = lin(gid, nwg); let k = lin(gid, nwg);
if (k >= P.nlinks) { return; } if (k >= P.nlinks) { return; }
let L = links[k]; let L = links[k];
let fi = post[L.i*P.n + L.node]; let fi = pget(L.i, L.node);
var v = fi; var v = fi;
// ступенчатая модель игнорирует долю пересечения: стенка ровно посередине между узлами // ступенчатая модель игнорирует долю пересечения: стенка ровно посередине между узлами
if (D.wall_mode == 3u) { if (D.wall_mode == 3u) {
f[L.ib*P.n + L.node] = fi; fset(L.ib, L.node, fi);
return; return;
} }
if (L.kind == 0u) { // q < 1/2, есть дальний жидкий сосед if (L.kind == 0u) { // q < 1/2, есть дальний жидкий сосед
v = 2.0*L.q*fi + (1.0 - 2.0*L.q)*post[L.i*P.n + L.far]; v = 2.0*L.q*fi + (1.0 - 2.0*L.q)*pget(L.i, L.far);
} else if (L.kind == 1u) { // q >= 1/2 } else if (L.kind == 1u) { // q >= 1/2
let h = 1.0/(2.0*L.q); let h = 1.0/(2.0*L.q);
v = h*fi + (1.0 - h)*post[L.ib*P.n + L.node]; v = h*fi + (1.0 - h)*pget(L.ib, L.node);
} }
f[L.ib*P.n + L.node] = v; fset(L.ib, L.node, v);
} }
@compute @workgroup_size(64) @compute @workgroup_size(64)
@@ -382,13 +384,13 @@ fn k_walls(@builtin(global_invocation_id) gid: vec3<u32>,
let x = lin(gid, nwg); let x = lin(gid, nwg);
if (x >= P.nx) { return; } if (x >= P.nx) { return; }
// зеркальное отражение: касательный импульс сохраняется, нормальный заворачивается // зеркальное отражение: касательный импульс сохраняется, нормальный заворачивается
f[2u*P.n + x] = f[4u*P.n + x]; fset(2u, x, fget(4u, x));
f[5u*P.n + x] = f[8u*P.n + x]; fset(5u, x, fget(8u, x));
f[6u*P.n + x] = f[7u*P.n + x]; fset(6u, x, fget(7u, x));
let t = (P.ny - 1u)*P.nx + x; let t = (P.ny - 1u)*P.nx + x;
f[4u*P.n + t] = f[2u*P.n + t]; fset(4u, t, fget(2u, t));
f[7u*P.n + t] = f[6u*P.n + t]; fset(7u, t, fget(6u, t));
f[8u*P.n + t] = f[5u*P.n + t]; fset(8u, t, fget(5u, t));
} }
@compute @workgroup_size(64) @compute @workgroup_size(64)
@@ -399,23 +401,23 @@ fn k_channel(@builtin(global_invocation_id) gid: vec3<u32>,
// вход: скоростной Zou-He по ВСЕМУ столбцу, включая угловые узлы // вход: скоростной Zou-He по ВСЕМУ столбцу, включая угловые узлы
let a = y*P.nx; let a = y*P.nx;
var fv: array<f32,9>; var fv: array<f32,9>;
load9(&fv, a, P.n); load9(&fv, a);
var gi = zou_he_inlet(fv, D.ux_in, D.uy_in); var gi = zou_he_inlet(fv, D.ux_in, D.uy_in);
for (var i = 0u; i < 9u; i = i + 1u) { f[i*P.n + a] = gi[i]; } for (var i = 0u; i < 9u; i = i + 1u) { fset(i, a, gi[i]); }
// выход: давление-Zou-He // выход: давление-Zou-He
let b = y*P.nx + P.nx - 1u; let b = y*P.nx + P.nx - 1u;
var gv: array<f32,9>; var gv: array<f32,9>;
load9(&gv, b, P.n); load9(&gv, b);
var uyo = 0.0; var uyo = 0.0;
if (D.outlet_extrap != 0u) { if (D.outlet_extrap != 0u) {
let c = y*P.nx + P.nx - 2u; let c = y*P.nx + P.nx - 2u;
var cv: array<f32,9>; var cv: array<f32,9>;
load9(&cv, c, P.n); load9(&cv, c);
let s = cv[0]+cv[1]+cv[2]+cv[3]+cv[4]+cv[5]+cv[6]+cv[7]+cv[8]; let s = cv[0]+cv[1]+cv[2]+cv[3]+cv[4]+cv[5]+cv[6]+cv[7]+cv[8];
uyo = ((cv[2]+cv[5]+cv[6]) - (cv[4]+cv[7]+cv[8])) / s; uyo = ((cv[2]+cv[5]+cv[6]) - (cv[4]+cv[7]+cv[8])) / s;
} }
var go = zou_he_outlet(gv, D.rho_out, uyo); var go = zou_he_outlet(gv, D.rho_out, uyo);
for (var i = 0u; i < 9u; i = i + 1u) { f[i*P.n + b] = go[i]; } for (var i = 0u; i < 9u; i = i + 1u) { fset(i, b, go[i]); }
} }
// сила и момент по GMEM, редукция одной рабочей группой // сила и момент по GMEM, редукция одной рабочей группой
@@ -436,8 +438,8 @@ fn k_force(@builtin(local_invocation_id) lid: vec3<u32>) {
loop { loop {
if (k >= P.nlinks) { break; } if (k >= P.nlinks) { break; }
let L = links[k]; let L = links[k];
let fp = post[L.i*P.n + L.node]; let fp = pget(L.i, L.node);
let fb = f[L.ib*P.n + L.node]; let fb = fget(L.ib, L.node);
let dfx = cx[L.i]*fp + cx[L.i]*fb; let dfx = cx[L.i]*fp + cx[L.i]*fb;
let dfy = cy[L.i]*fp + cy[L.i]*fb; let dfy = cy[L.i]*fp + cy[L.i]*fb;
// плечо до точки пересечения линка со стенкой, а не до узла // плечо до точки пересечения линка со стенкой, а не до узла
@@ -488,7 +490,7 @@ fn k_stats1(@builtin(global_invocation_id) gid: vec3<u32>,
p.cnt = 0.0; p.degen = 0.0; p.xineg = 0.0; p.cnt = 0.0; p.degen = 0.0; p.xineg = 0.0;
if (nd < P.n && solid[nd] == 0u) { if (nd < P.n && solid[nd] == 0u) {
var fv: array<f32,9>; var fv: array<f32,9>;
load9(&fv, nd, P.n); load9(&fv, nd);
let m = macros9(fv); let m = macros9(fv);
p.rho = m.x; p.rho = m.x;
p.maxu = sqrt(m.y*m.y + m.z*m.z); p.maxu = sqrt(m.y*m.y + m.z*m.z);
@@ -506,7 +508,7 @@ fn k_stats1(@builtin(global_invocation_id) gid: vec3<u32>,
// зонд следа снимается с того уровня, на котором он лежит // зонд следа снимается с того уровня, на котором он лежит
if ((P.flags & 2u) != 0u && nd == P.probe_node) { if ((P.flags & 2u) != 0u && nd == P.probe_node) {
var fv: array<f32,9>; var fv: array<f32,9>;
load9(&fv, nd, P.n); load9(&fv, nd);
let m = macros9(fv); let m = macros9(fv);
results[D.slot*SLOT + 3u] = m.z; results[D.slot*SLOT + 3u] = m.z;
} }
@@ -626,7 +628,7 @@ fn k_stats2(@builtin(local_invocation_id) lid: vec3<u32>) {
// хранилища прошлого шага. // хранилища прошлого шага.
fn u_at_t(nd: u32) -> vec2<f32> { fn u_at_t(nd: u32) -> vec2<f32> {
var ff: array<f32,9>; var ff: array<f32,9>;
for (var i = 0u; i < 9u; i = i + 1u) { ff[i] = post[i*P.n + nd]; } for (var i = 0u; i < 9u; i = i + 1u) { ff[i] = pget(i, nd); }
let m = macros9(ff); let m = macros9(ff);
return vec2<f32>(m.y, m.z); return vec2<f32>(m.y, m.z);
} }
@@ -733,15 +735,15 @@ fn k_moment_wall(@builtin(global_invocation_id) gid: vec3<u32>,
var opp = array<u32,9>(0u, 3u, 4u, 1u, 2u, 7u, 8u, 5u, 6u); var opp = array<u32,9>(0u, 3u, 4u, 1u, 2u, 7u, 8u, 5u, 6u);
var rho = 0.0; var rho = 0.0;
for (var i = 0u; i < 9u; i = i + 1u) { for (var i = 0u; i < 9u; i = i + 1u) {
if (missing[i] == 1u) { rho = rho + post[opp[i]*P.n + node]; } if (missing[i] == 1u) { rho = rho + pget(opp[i], node); }
else { rho = rho + f[i*P.n + node]; } else { rho = rho + fget(i, node); }
} }
let g4 = grad_u_at_t(node); let g4 = grad_u_at_t(node);
var g = moment_wall9(rho, ux, uy, g4.x, g4.y, g4.z, g4.w, var g = moment_wall9(rho, ux, uy, g4.x, g4.y, g4.z, g4.w,
beta[node % P.nx], select(0u, 1u, D.wall_mode == 2u)); beta[node % P.nx], select(0u, 1u, D.wall_mode == 2u));
for (var i = 0u; i < 9u; i = i + 1u) { for (var i = 0u; i < 9u; i = i + 1u) {
if (missing[i] == 1u) { f[i*P.n + node] = g[i]; } if (missing[i] == 1u) { fset(i, node, g[i]); }
} }
} }
@@ -764,7 +766,7 @@ fn k_ghost(@builtin(global_invocation_id) gid: vec3<u32>,
var neq = array<f32,9>(0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0); var neq = array<f32,9>(0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0);
for (var j = 0u; j < 4u; j = j + 1u) { for (var j = 0u; j < 4u; j = j + 1u) {
var cf: array<f32,9>; var cf: array<f32,9>;
for (var i = 0u; i < 9u; i = i + 1u) { cf[i] = amr_src[i*A.ccount + cs[j]]; } for (var i = 0u; i < 9u; i = i + 1u) { cf[i] = amr_src[i*A.cstride + cs[j]]; }
let m = macros9(cf); let m = macros9(cf);
rho = rho + ws[j]*m.x; rho = rho + ws[j]*m.x;
ux = ux + ws[j]*m.y; ux = ux + ws[j]*m.y;
@@ -785,7 +787,7 @@ fn k_fill(@builtin(global_invocation_id) gid: vec3<u32>,
let g = ghosts[k]; let g = ghosts[k];
// временная интерполяция рамки между состояниями L0 «до» и «после» шага // временная интерполяция рамки между состояниями L0 «до» и «после» шага
for (var i = 0u; i < 9u; i = i + 1u) { for (var i = 0u; i < 9u; i = i + 1u) {
amr_dst[i*A.fcount + g.fine] = (1.0 - A.w)*gh_a[k*9u + i] + A.w*gh_b[k*9u + i]; amr_dst[i*A.fstride + g.fine] = (1.0 - A.w)*gh_a[k*9u + i] + A.w*gh_b[k*9u + i];
} }
} }
@@ -796,11 +798,11 @@ fn k_restrict(@builtin(global_invocation_id) gid: vec3<u32>,
if (k >= A.nrestrict) { return; } if (k >= A.nrestrict) { return; }
let pr = rest[k]; let pr = rest[k];
var ff: array<f32,9>; var ff: array<f32,9>;
for (var i = 0u; i < 9u; i = i + 1u) { ff[i] = amr_src[i*A.fcount + pr.y]; } for (var i = 0u; i < 9u; i = i + 1u) { ff[i] = amr_src[i*A.fstride + pr.y]; }
let m = macros9(ff); let m = macros9(ff);
var fe = feq9(m.x, m.y, m.z); var fe = feq9(m.x, m.y, m.z);
for (var i = 0u; i < 9u; i = i + 1u) { for (var i = 0u; i < 9u; i = i + 1u) {
amr_dst[i*A.ccount + pr.x] = fe[i] + A.rfc*(ff[i] - fe[i]); amr_dst[i*A.cstride + pr.x] = fe[i] + A.rfc*(ff[i] - fe[i]);
} }
} }
"#; "#;
@@ -813,6 +815,8 @@ struct GpuLevel {
nx: usize, nx: usize,
ny: usize, ny: usize,
n: usize, n: usize,
/// шаг между направлениями в `f`; см. dir_stride
stride: usize,
f: wgpu::Buffer, f: wgpu::Buffer,
/// Стабилизатор γ поузлово — нужен для картинки; забирается с устройства как есть. /// Стабилизатор γ поузлово — нужен для картинки; забирается с устройства как есть.
gam: wgpu::Buffer, gam: wgpu::Buffer,
@@ -833,12 +837,27 @@ impl GpuLevel {
/// Переложить готовое стартовое поле (построенное общим кодом в `cpu::initial_field`) /// Переложить готовое стартовое поле (построенное общим кодом в `cpu::initial_field`)
/// из AoS в раскладку SoA, которой пользуется GPU. /// из AoS в раскладку SoA, которой пользуется GPU.
fn to_soa(f: &[[R; math::Q]]) -> Vec<f32> { /// Выравнивание шага между направлениями, во ФЛОАТАХ.
///
/// Смещение каждой привязки обязано быть кратно `min_storage_buffer_offset_alignment`,
/// а он равен 256 байтам у всех известных реализаций — это 64 значения f32. Шаг
/// выравнивается всегда, даже когда привязка одна: так раскладка одна на оба варианта
/// шейдера, и не надо помнить, какой из них сейчас собран. Перерасход памяти — меньше
/// сотой доли процента.
const DIR_ALIGN: usize = 64;
/// Расстояние между началами соседних направлений в буфере популяций, во флоатах.
fn dir_stride(n: usize) -> usize {
n.div_ceil(DIR_ALIGN) * DIR_ALIGN
}
fn to_soa(f: &[[R; math::Q]], stride: usize) -> Vec<f32> {
let n = f.len(); let n = f.len();
let mut v = vec![0.0f32; 9 * n]; debug_assert!(stride >= n);
for (k, cell) in f.iter().enumerate() { let mut v = vec![0.0f32; 9 * stride];
for i in 0..9 { for (k, c) in f.iter().enumerate() {
v[i * n + k] = cell[i] as f32; for i in 0..math::Q {
v[i * stride + k] = c[i] as f32;
} }
} }
v v
@@ -856,9 +875,11 @@ fn make_level(
flags: u32, flags: u32,
init_field: &[[R; math::Q]], init_field: &[[R; math::Q]],
wall_layout: &wgpu::BindGroupLayout, wall_layout: &wgpu::BindGroupLayout,
split: bool,
) -> GpuLevel { ) -> GpuLevel {
let n = nx * ny; let n = nx * ny;
let init = to_soa(init_field); let stride = dir_stride(n);
let init = to_soa(init_field, stride);
let mkf = |label: &str| { let mkf = |label: &str| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label), label: Some(label),
@@ -930,26 +951,41 @@ fn make_level(
probe_node, probe_node,
flags, flags,
nwall, nwall,
_pad: [0; 3], stride: stride as u32,
_pad: [0; 2],
}), }),
usage: wgpu::BufferUsages::UNIFORM, usage: wgpu::BufferUsages::UNIFORM,
}); });
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { // Общая часть связки одинакова в обоих вариантах; различаются только популяции.
label: Some("level"), let mut entries = vec![
layout,
entries: &[
bind(0, &f),
bind(1, &post),
bind(2, &gam), bind(2, &gam),
bind(3, &solid_buf), bind(3, &solid_buf),
bind(4, &links_buf), bind(4, &links_buf),
bind(5, &beta_buf), bind(5, &beta_buf),
bind(6, &parts), bind(6, &parts),
bind(7, &params), bind(7, &params),
], ];
if split {
// Тот же буфер, но показанный шейдеру по одному направлению: каждая привязка
// укладывается в n*4 байта вместо n*9*4, и предел драйвера на размер ОДНОЙ
// привязки перестаёт быть потолком для размера сетки.
for i in 0..math::Q {
let off = (i * stride * 4) as u64;
let len = (n * 4) as u64;
entries.push(bind_range(POP_F0 + i as u32, &f, off, len));
entries.push(bind_range(POP_P0 + i as u32, &post, off, len));
}
} else {
entries.push(bind(0, &f));
entries.push(bind(1, &post));
}
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("level"),
layout,
entries: &entries,
}); });
GpuLevel { nx, ny, n, f, gam, bind, wall_bind, nwall, parts_count, nlinks: links.len() as u32 } GpuLevel { nx, ny, n, stride, f, gam, bind, wall_bind, nwall, parts_count, nlinks: links.len() as u32 }
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -1014,6 +1050,22 @@ impl Sim {
pub fn new(spec: Spec) -> Result<Sim, String> { pub fn new(spec: Spec) -> Result<Sim, String> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY, backends: wgpu::Backends::PRIMARY,
// Флаги читаются из окружения, и нужно это ровно ради одного —
// WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER.
//
// По умолчанию wgpu ПРЯЧЕТ адаптеры, не прошедшие набор тестов соответствия
// Vulkan («Adapter is not Vulkan compliant, hiding adapter»), и делает это
// молча: request_adapter просто возвращает None. Под это правило попадает
// dzn (Dozen) — драйвер Mesa, транслирующий Vulkan в D3D12. В WSL2 он
// единственный способ добраться до карты: Linux-драйвера Vulkan у NVIDIA
// там нет, устройство отдаётся через /dev/dxg по протоколу WDDM.
//
// Поведение по умолчанию НЕ меняется: без переменной окружения
// несоответствующие адаптеры по-прежнему скрыты. Переменная — осознанное
// согласие считать на непроверенном драйвере, и прежде чем на нём считать,
// надо прогнать bench/parity.py: он сверяет GPU-путь с CPU в двойной
// точности на течениях с точным решением.
flags: wgpu::InstanceFlags::from_build_config().with_env(),
..Default::default() ..Default::default()
}); });
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
@@ -1051,6 +1103,45 @@ impl Sim {
} }
} }
// Влезает ли массив популяций в ОДНУ привязку. Считаем по самому крупному
// уровню: у патча измельчения своих узлов может оказаться больше, чем у L0.
let max_nodes = {
let n0 = spec.nx * spec.ny;
let n1 = match (spec.refine > 1, spec.patch) {
(true, Some((ax, bx, ay, by))) => {
(spec.refine * (bx - ax) + 1) * (spec.refine * (by - ay) + 1)
}
_ => 0,
};
n0.max(n1)
};
let need = (math::Q * dir_stride(max_nodes) * 4) as u64;
// Переменная окружения нужна не для работы, а для проверки: без неё раздельный
// вариант включается только на драйверах с малым пределом, и сверить два
// варианта на одной карте было бы нечем.
let split = need > adapter.limits().max_storage_buffer_binding_size as u64
|| std::env::var("KBC2D_SPLIT_POPULATIONS").is_ok_and(|v| v != "0");
if split {
// Шаг между направлениями выровнен на DIR_ALIGN значений f32. Если адаптер
// требует более крупного выравнивания смещений, раздельные привязки собрать
// нельзя — лучше сказать это прямо, чем ловить невнятную ошибку валидации.
let align = adapter.limits().min_storage_buffer_offset_alignment as usize;
if (DIR_ALIGN * 4) % align != 0 {
return Err(format!(
"адаптер требует выравнивания смещений на {align} байт, а раскладка популяций рассчитана на {}. Считай на CPU либо на другом драйвере",
DIR_ALIGN * 4
));
}
let per = (dir_stride(max_nodes) * 4) as u64;
if per > adapter.limits().max_storage_buffer_binding_size as u64 {
return Err(format!(
"адаптер ограничивает привязку {} МиБ, а одному направлению нужно {} МиБ. Сетка слишком крупная для этого драйвера — считай на CPU либо на драйвере без такого предела",
adapter.limits().max_storage_buffer_binding_size / 1048576,
per / 1048576
));
}
}
// ── топология берётся из процессорного бэкенда, а не строится заново ── // ── топология берётся из процессорного бэкенда, а не строится заново ──
let geom0 = cpu::Geom::build(spec.nx, spec.ny, &spec.scene); let geom0 = cpu::Geom::build(spec.nx, spec.ny, &spec.scene);
let fluid_count = geom0.solid.iter().filter(|s| !**s).count() as R; let fluid_count = geom0.solid.iter().filter(|s| !**s).count() as R;
@@ -1063,10 +1154,10 @@ impl Sim {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("kbc2d.wgsl"), label: Some("kbc2d.wgsl"),
source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(SHADER)), source: wgpu::ShaderSource::Wgsl(Cow::Owned(build_shader(split))),
}); });
let bgl_level = level_layout(&device); let bgl_level = level_layout(&device, split);
let bgl_dyn = dyn_layout(&device); let bgl_dyn = dyn_layout(&device);
let bgl_amr = amr_layout(&device); let bgl_amr = amr_layout(&device);
let bgl_wall = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { let bgl_wall = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -1179,11 +1270,12 @@ impl Sim {
taper: if spec.init_uniform { spec.init_taper } else { 0.0 }, taper: if spec.init_uniform { spec.init_taper } else { 0.0 },
}), }),
&bgl_wall, &bgl_wall,
split,
); );
let pre = device.create_buffer(&wgpu::BufferDescriptor { let pre = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("pre"), label: Some("pre"),
size: (9 * l0.n * 4) as u64, size: (math::Q * l0.stride * 4) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: false,
}); });
@@ -1219,6 +1311,7 @@ impl Sim {
taper: if spec.init_uniform { spec.init_taper * r as R } else { 0.0 }, taper: if spec.init_uniform { spec.init_taper * r as R } else { 0.0 },
}), }),
&bgl_wall, &bgl_wall,
split,
); );
let ghosts: Vec<GGhost> = patch let ghosts: Vec<GGhost> = patch
@@ -1248,8 +1341,8 @@ impl Sim {
let base = AmrParams { let base = AmrParams {
nghost: ghosts.len() as u32, nghost: ghosts.len() as u32,
nrestrict: rest.len() as u32, nrestrict: rest.len() as u32,
ccount: l0.n as u32, cstride: l0.stride as u32,
fcount: lvl1.n as u32, fstride: lvl1.stride as u32,
r01: r01 as f32, r01: r01 as f32,
rfc: (1.0 / r01) as f32, rfc: (1.0 / r01) as f32,
w: 0.0, w: 0.0,
@@ -1324,7 +1417,7 @@ impl Sim {
let readback = device.create_buffer(&wgpu::BufferDescriptor { let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"), label: Some("readback"),
size: (9 * l0.n * 4) as u64, size: (math::Q * l0.stride * 4) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: false,
}); });
@@ -1420,7 +1513,7 @@ impl Sim {
// «до» — состояние L0 перед столкновением, нужно как старый край рамки патча // «до» — состояние L0 перед столкновением, нужно как старый край рамки патча
if self.amr.is_some() { if self.amr.is_some() {
enc.copy_buffer_to_buffer(&self.l0.f, 0, &self.pre, 0, (9 * self.l0.n * 4) as u64); enc.copy_buffer_to_buffer(&self.l0.f, 0, &self.pre, 0, (math::Q * self.l0.stride * 4) as u64);
} }
{ {
@@ -1620,7 +1713,7 @@ impl Sim {
/// Скачать популяции L0 на хост (нужно для кадров и проверки на NaN). /// Скачать популяции L0 на хост (нужно для кадров и проверки на NaN).
fn download_l0(&self) -> Vec<f32> { fn download_l0(&self) -> Vec<f32> {
self.download(&self.l0.f, (9 * self.l0.n * 4) as u64) self.download(&self.l0.f, (math::Q * self.l0.stride * 4) as u64)
} }
/// Скачать произвольный буфер уровня. Буфер приёма выделен один раз при сборке под самый /// Скачать произвольный буфер уровня. Буфер приёма выделен один раз при сборке под самый
@@ -1653,11 +1746,12 @@ impl Sim {
/// Полное поле скорости уровня L0 — для метрик эталонных течений и радиуса влияния. /// Полное поле скорости уровня L0 — для метрик эталонных течений и радиуса влияния.
pub fn sample_velocity(&self) -> (Vec<R>, Vec<R>) { pub fn sample_velocity(&self) -> (Vec<R>, Vec<R>) {
let n = self.l0.n; let n = self.l0.n;
let stride = self.l0.stride;
let raw = self.download_l0(); let raw = self.download_l0();
let mut ux = Vec::with_capacity(n); let mut ux = Vec::with_capacity(n);
let mut uy = Vec::with_capacity(n); let mut uy = Vec::with_capacity(n);
for k in 0..n { for k in 0..n {
let c: [R; 9] = std::array::from_fn(|i| raw[i * n + k] as R); let c: [R; 9] = std::array::from_fn(|i| raw[i * stride + k] as R);
let (_, a, b) = math::macros(&c); let (_, a, b) = math::macros(&c);
ux.push(a); ux.push(a);
uy.push(b); uy.push(b);
@@ -1668,14 +1762,15 @@ impl Sim {
/// Срез вдоль осевой линии: (ρ, u_x) по столбцам. На GPU это полное скачивание поля, /// Срез вдоль осевой линии: (ρ, u_x) по столбцам. На GPU это полное скачивание поля,
/// поэтому x–t диагностика включается редким шагом и только там, где нужна. /// поэтому x–t диагностика включается редким шагом и только там, где нужна.
pub fn sample_centerline(&self) -> (Vec<R>, Vec<R>) { pub fn sample_centerline(&self) -> (Vec<R>, Vec<R>) {
let (nx, ny, n) = (self.l0.nx, self.l0.ny, self.l0.n); let (nx, ny) = (self.l0.nx, self.l0.ny);
let stride = self.l0.stride;
let raw = self.download_l0(); let raw = self.download_l0();
let y = ny / 2; let y = ny / 2;
let mut rho = Vec::with_capacity(nx); let mut rho = Vec::with_capacity(nx);
let mut ux = Vec::with_capacity(nx); let mut ux = Vec::with_capacity(nx);
for x in 0..nx { for x in 0..nx {
let k = y * nx + x; let k = y * nx + x;
let c: [R; 9] = std::array::from_fn(|i| raw[i * n + k] as R); let c: [R; 9] = std::array::from_fn(|i| raw[i * stride + k] as R);
let (r, a, _) = math::macros(&c); let (r, a, _) = math::macros(&c);
rho.push(r); rho.push(r);
ux.push(a); ux.push(a);
@@ -1689,9 +1784,10 @@ impl Sim {
pub fn sample_field(&self, kind: FieldKind) -> (Vec<R>, Vec<bool>) { pub fn sample_field(&self, kind: FieldKind) -> (Vec<R>, Vec<bool>) {
let (nx, ny, n) = (self.l0.nx, self.l0.ny, self.l0.n); let (nx, ny, n) = (self.l0.nx, self.l0.ny, self.l0.n);
let stride = self.l0.stride;
let raw = self.download_l0(); let raw = self.download_l0();
let node = |k: usize| -> [R; 9] { let node = |k: usize| -> [R; 9] {
std::array::from_fn(|i| raw[i * n + k] as R) std::array::from_fn(|i| raw[i * stride + k] as R)
}; };
let mut out = vec![0.0; n]; let mut out = vec![0.0; n];
match kind { match kind {
@@ -1766,6 +1862,19 @@ fn bind(binding: u32, buf: &wgpu::Buffer) -> wgpu::BindGroupEntry<'_> {
wgpu::BindGroupEntry { binding, resource: buf.as_entire_binding() } wgpu::BindGroupEntry { binding, resource: buf.as_entire_binding() }
} }
/// Привязать кусок буфера. Смещение обязано быть кратно
/// `min_storage_buffer_offset_alignment` — этим и занят `dir_stride`.
fn bind_range(binding: u32, buf: &wgpu::Buffer, offset: u64, size: u64) -> wgpu::BindGroupEntry<'_> {
wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: buf,
offset,
size: std::num::NonZeroU64::new(size),
}),
}
}
fn storage(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer { fn storage(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor { device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label), label: Some(label),
@@ -1808,10 +1917,111 @@ fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntr
} }
} }
fn level_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { /// Номера привязок раздельных популяций. Взяты выше занятых (2..7), чтобы остальная
/// часть связки не меняла нумерацию между вариантами шейдера.
const POP_F0: u32 = 8;
const POP_P0: u32 = 17;
/// Собрать WGSL под конкретный адаптер.
///
/// `split` — раскладывать ли популяции по девяти привязкам вместо одной общей.
///
/// Зачем это вообще. Драйверы ограничивают размер ОДНОЙ привязки storage-буфера, и у
/// разных он разный: нативные дают гигабайты, а dzn (трансляция Vulkan в D3D12 —
/// единственный способ добраться до карты из WSL2, где Linux-драйвера Vulkan у NVIDIA
/// нет) даёт 128 МиБ. Массив популяций занимает nx*ny*9*4 байта, так что на dzn потолок
/// выходит 3.73 млн узлов — сетка 2048x2048 уже не проходит. При этом сам БУФЕР держать
/// разрешено: max_buffer_size там 2 ГиБ. Ограничена только привязка.
///
/// Отсюда второй вариант: тот же буфер показывается девятью привязками, по одному
/// направлению в каждой, и потолок поднимается в девять раз — до 33.5 млн узлов, чего
/// хватает всей кампании. Платой служит switch в аксессорах, поэтому там, где предела
/// нет, собирается прежний общий вариант без всякого switch.
fn build_shader(split: bool) -> String {
let mut d = String::new();
if split {
for i in 0..math::Q {
d += &format!(
"@group(0) @binding({}) var<storage, read_write> f{i} : array<f32>;
",
POP_F0 as usize + i
);
d += &format!(
"@group(0) @binding({}) var<storage, read_write> q{i} : array<f32>;
",
POP_P0 as usize + i
);
}
let arm = |pfx: &str, body: &dyn Fn(usize) -> String| {
let mut t = String::new();
for i in 0..math::Q - 1 {
t += &format!(" case {i}u: {{ {} }}
", body(i));
}
t += &format!(" default: {{ {} }}
", body(math::Q - 1));
let _ = pfx;
t
};
d += "fn fget(i: u32, nd: u32) -> f32 {
switch i {
";
d += &arm("f", &|i| format!("return f{i}[nd];"));
d += " }
}
";
d += "fn fset(i: u32, nd: u32, v: f32) {
switch i {
";
d += &arm("f", &|i| format!("f{i}[nd] = v;"));
d += " }
}
";
d += "fn pget(i: u32, nd: u32) -> f32 {
switch i {
";
d += &arm("q", &|i| format!("return q{i}[nd];"));
d += " }
}
";
d += "fn pset(i: u32, nd: u32, v: f32) {
switch i {
";
d += &arm("q", &|i| format!("q{i}[nd] = v;"));
d += " }
}
";
} else {
d += "@group(0) @binding(0) var<storage, read_write> f : array<f32>;
";
d += "@group(0) @binding(1) var<storage, read_write> post : array<f32>;
";
d += "fn fget(i: u32, nd: u32) -> f32 { return f[i*P.stride + nd]; }
";
d += "fn fset(i: u32, nd: u32, v: f32) { f[i*P.stride + nd] = v; }
";
d += "fn pget(i: u32, nd: u32) -> f32 { return post[i*P.stride + nd]; }
";
d += "fn pset(i: u32, nd: u32, v: f32) { post[i*P.stride + nd] = v; }
";
}
SHADER.replace("//__POPULATIONS__", &d)
}
fn level_layout(device: &wgpu::Device, split: bool) -> wgpu::BindGroupLayout {
let mut entries = vec![rw(2), ro(3), ro(4), ro(5), rw(6), un(7)];
if split {
for i in 0..math::Q as u32 {
entries.push(rw(POP_F0 + i));
entries.push(rw(POP_P0 + i));
}
} else {
entries.push(rw(0));
entries.push(rw(1));
}
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("level"), label: Some("level"),
entries: &[rw(0), rw(1), rw(2), ro(3), ro(4), ro(5), rw(6), un(7)], entries: &entries,
}) })
} }
fn dyn_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { fn dyn_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
+4 -2
View File
@@ -31,8 +31,10 @@
## Где какая математика (для ревизии) ## Где какая математика (для ревизии)
- **Столкновение** — `collision.py`. KBC-N1: `f ← f − β(2Δs + γΔh)`, где `Δs = Ps·(f−feq)` — - **Столкновение** — `collision.py`. KBC-N1: `f ← f − β(2Δs + γΔh)`, где `Δs = Ps·(f−feq)` —
проекция на сдвиг-моменты, `γ` — энтропийный лимитер. Чтобы поэкспериментировать: проекция на сдвиг-моменты, `γ` — энтропийный лимитер. Оператор жёстко зафиксирован:
переключить `cfg.collision="bgk"`, либо добавить TRT новой функцией и зарегистрировать в `get`. модуль объявляет `collide = kbc_collide`, поля `cfg.collision` и реестра операторов
не существует (см. раздел «Статус» ниже). Чтобы поэкспериментировать с BGK или TRT,
придётся вводить и то и другое — это осознанно не сделано.
- **Перенос** — `streaming.py`. Чистая пул-схема; на физику влияет только корректность сдвигов. - **Перенос** — `streaming.py`. Чистая пул-схема; на физику влияет только корректность сдвигов.
- **Силы** — `forces.py`. Обмен импульсом по линкам тела: - **Силы** — `forces.py`. Обмен импульсом по линкам тела:
`F = Σ_links c_i (f_i^{после столкн.} + f_ī^{после стриминга})`. Второй член — из поля ПОСЛЕ `F = Σ_links c_i (f_i^{после столкн.} + f_ī^{после стриминга})`. Второй член — из поля ПОСЛЕ