Валидационная кампания на 115 прогонов и Docker-образ для сервера

bench/ — список сценариев (порождается gen_scenarios.py, а не правится
руками), драйверы под Linux и Windows, предполётная проверка и описание.
Девять групп: эталоны первоисточников, цилиндр против литературы, модели
стенки и субсеточность, профили крыла, сложная и множественная геометрия,
границы домена, старт и время жизни, внутренние инварианты, сверхмелкие
сетки до 4096×2048. Стоимость планируется в обновлениях узлов —
единственной переносимой между машинами мере; в часы драйвер переводит её
по фактическим MLUPS, которые замеряет на месте (--calibrate). Итого
3.76e14 обновлений, ≈90 часов на 4070 Ti.

Гифки пишутся на всю длительность прогона в реальном времени, 10 кадр/с.
Число кадров этим задано жёстко, поэтому размер регулируется только кадром:
замерено 0.103 байта на пиксель после LZW, отсюда бюджет кадры×пиксели с
нижней границей по ширине. Итог 4.4 ГБ, самый тяжёлый файл 226 МБ.

preflight.py гоняет каждый сценарий на два шага. Окупился сразу: поймал,
что вся группа сверхмелких сеток падала на пределе GPU, а девять прогонов
группы F передавали --body-x дважды. Оба отказа проявились бы только на
сервере, часов через двадцать после старта кампании.

Docker: двухстадийная сборка, в образе libvulkan1 и vulkan-tools.
NVIDIA Container Toolkit подкладывает Vulkan-ICD только при graphics в
NVIDIA_DRIVER_CAPABILITIES — без него wgpu не увидит карту, поэтому
capabilities прописаны в образе, а vulkaninfo лежит внутрь для проверки.
ENTRYPOINT — драйвер кампании, CMD по умолчанию --dry-run, чтобы случайный
docker run не запустил сточасовую задачу. Проверено локально: образ
собирается, смоук проходит с монтированием результатов на хост, физика
совпадает с хостовой до последней цифры, --backend gpu без карты отказывает
явно. GPU-путь в контейнере проверяется только на машине с картой.

В README поправлено разделение вкладов в продольную акустику: измерением
2×2 показано, что 74.9% → 1.1% даёт ОДНОРОДНЫЙ СТАРТ, причём при полностью
выключенной губке, а губка снимает лишь остаток (1.12% → 0.83% при
утроении длины). При старте из покоя губка не помогает совсем — у стоячей
четвертьволновой моды там узел давления, где вязкость её не трогает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 17:36:40 +03:00
co-authored by Claude Opus 5
parent 26a8475396
commit 8d17bc8014
11 changed files with 6114 additions and 39 deletions
+3
View File
@@ -52,6 +52,9 @@ target/
docs/theory/2d_solver/*.gif docs/theory/2d_solver/*.gif
docs/theory/2d_solver/*.csv docs/theory/2d_solver/*.csv
docs/theory/2d_solver/out/ docs/theory/2d_solver/out/
# Результаты валидационной кампании — гигабайты гифок и рядов. Сам список сценариев
# (bench/scenarios.json) лежит рядом с драйверами и версионируется.
docs/theory/2d_solver/bench/out/
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Редакторы и ОС # Редакторы и ОС
+14
View File
@@ -0,0 +1,14 @@
# Каталог сборки cargo — сотни мегабайт, внутри образа он собирается заново.
target/
# Результаты прогонов: гифки и ряды монтируются с хоста, в образ не кладутся.
bench/out/
*.gif
*.csv
*.json
!bench/scenarios.json
# Мусор редакторов и ОС
.vs/
.vscode/
.idea/
.DS_Store
Thumbs.db
+51
View File
@@ -0,0 +1,51 @@
# Образ для развёртывания решателя и валидационной кампании на сервере с NVIDIA.
#
# Сборка и запуск:
# docker build -t kbc2d docs/theory/2d_solver
# docker run --rm --gpus all kbc2d --calibrate # проверить, что GPU виден
# 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` и убедиться, что карта видна.
# ── сборка ───────────────────────────────────────────────────────────────────
# Версия тулчейна закреплена: та же, на которой решатель собирался и проверялся.
FROM rust:1.97-bookworm AS build
WORKDIR /src
# Сначала только манифесты — тогда слой с зависимостями переиспользуется, пока они не менялись.
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
&& cargo build --release --locked 2>/dev/null || true \
&& rm -rf src
COPY src ./src
# touch нужен, чтобы cargo не спутал новые исходники с заглушкой из слоя выше
RUN touch src/main.rs && cargo build --release --locked
# ── исполнение ───────────────────────────────────────────────────────────────
FROM debian:bookworm-slim
LABEL org.opencontainers.image.title="kbc2d"
LABEL org.opencontainers.image.description="Двумерный решатель LBM D2Q9 с энтропийным столкновением KBC"
RUN apt-get update && apt-get install -y --no-install-recommends \
libvulkan1 vulkan-tools python3 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Без graphics в списке возможностей Vulkan-ICD внутрь не попадёт — см. шапку.
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics
WORKDIR /work
COPY --from=build /src/target/release/kbc2d /work/target/release/kbc2d
COPY bench /work/bench
RUN chmod +x /work/bench/run_campaign.sh
# Кампания идёт десятки часов, поэтому по умолчанию образ НЕ запускает её: случайный
# `docker run` покажет смету и выйдет.
WORKDIR /work/bench
ENTRYPOINT ["./run_campaign.sh"]
CMD ["--dry-run"]
+198 -39
View File
@@ -24,6 +24,9 @@ Multi-Relaxation Models for Simulation of Fluid Turbulence* (arXiv:1507.02509,
| `src/main.rs` | **запуск и оркестрирование.** Разбор параметров, сборка постановки, цикл по шагам, живой вывод и итоговый отчёт, выгрузка рядов в CSV. Здесь же контракт `Spec` / `StepRec` / `FieldKind`, общий для обоих бэкендов. | | `src/main.rs` | **запуск и оркестрирование.** Разбор параметров, сборка постановки, цикл по шагам, живой вывод и итоговый отчёт, выгрузка рядов в CSV. Здесь же контракт `Spec` / `StepRec` / `FieldKind`, общий для обоих бэкендов. |
| `src/gif.rs` | **создание гифок.** Тайминг относительно физического времени, палитры, нормировка, служебная надпись, кодирование. | | `src/gif.rs` | **создание гифок.** Тайминг относительно физического времени, палитры, нормировка, служебная надпись, кодирование. |
Рядом, вне этого разделения: `bench/` — валидационная кампания (список сценариев и драйверы под
Linux и Windows), `Dockerfile` с `docker-compose.yml` — образ для развёртывания на сервере.
## Сборка и запуск ## Сборка и запуск
Нужен Rust 1.75+. Нужен Rust 1.75+.
@@ -31,10 +34,16 @@ Multi-Relaxation Models for Simulation of Fluid Turbulence* (arXiv:1507.02509,
```sh ```sh
cargo build --release # с GPU-бэкендом cargo build --release # с GPU-бэкендом
cargo build --release --no-default-features # только CPU (без wgpu) cargo build --release --no-default-features # только CPU (без wgpu)
cargo test --release # 22 быстрых теста cargo test --release # 29 быстрых тестов
cargo test --release -- --include-ignored # плюс эталоны статьи (~18 с) cargo test --release -- --include-ignored # плюс 4 эталона статьи (~18 с)
``` ```
Сборка без GPU кладёт бинарь по тому же пути, поэтому собирать её ПОСЛЕДНЕЙ нельзя: `kbc2d`
окажется перезаписан вариантом без wgpu, и `--backend gpu` будет отказывать. Порядок —
сначала `--no-default-features`, потом обычная.
На сервер удобнее ставить образом — см. [Развёртывание](#развёртывание-docker).
Пример: цилиндр Re=150, гифка завихренности в реальном времени. Пример: цилиндр Re=150, гифка завихренности в реальном времени.
```sh ```sh
@@ -99,19 +108,28 @@ cargo test --release -- --include-ignored # плюс эталоны стат
## Что параметризовано ## Что параметризовано
- **поток**: скорость (м/с), направление, число Рейнольдса, решёточная скорость (число Маха); - **поток**: скорость (м/с), направление, число Рейнольдса, решёточная скорость (число Маха);
- **постановка**: `--case channel` — обтекание тела; `taylor-green`, `shear-layer`,
`decaying-turbulence` — периодические эталоны из статей, без тела и граничных условий;
- **сетка**: размеры домена и размер ячейки в метрах (плотность сетки), коэффициент измельчения - **сетка**: размеры домена и размер ячейки в метрах (плотность сетки), коэффициент измельчения
вложенного патча и его границы; вложенного патча и его границы;
- **тело**: семь форм на выбор — `cylinder`, `square`, `diamond`, `ellipse`, `naca`, `triangle`, - **тело**: восемь форм на выбор — `cylinder`, `square`, `diamond`, `ellipse`, `naca`,
`plate` — плюс характерный размер, относительная толщина, угол атаки и положение; `triangle`, `plate`, `polygon` — плюс характерный размер, относительная толщина, угол атаки и
положение. Профиль задаётся четырёхзначным кодом (`--naca 4412`, с кривизной), произвольный
контур — списком вершин (`--poly "x,y;x,y;…"`), **несколько тел сразу** — списком
`--bodies "cylinder:d=24,x=120,y=120; naca:d=48,x=260,y=120,a=8"` (сила считается по каждому
телу отдельно, до четырёх);
- **время**: длительность прогона — либо числом шагов (`--steps`), либо прямо в СЕКУНДАХ - **время**: длительность прогона — либо числом шагов (`--steps`), либо прямо в СЕКУНДАХ
физического времени (`--time`, число шагов считается как time/δt); начальное поле (однородный физического времени (`--time`, число шагов считается как time/δt); начальное поле (однородный
поток либо покой с разгоном), длина разгона, амплитуда и длительность стартового возмущения; поток либо покой с разгоном), длина разгона, амплитуда и длительность стартового возмущения;
- **схема**: оператор столкновения (`kbc`/`bgk`), состав сдвиговой части (`n1`/`n2`), модель - **схема**: оператор столкновения (`kbc`/`bgk`), состав сдвиговой части (`n1`/`n2`), модель
стенки на теле (`bouzidi`/`grad`/`staircase`), режим выхода, поглощающая губка, бэкенд, стенки на теле (`hrr`/`grad`/`bouzidi`/`staircase`), режим выхода, поглощающие губки перед
число потоков; выходом и после входа, бэкенд, число потоков;
- **анимация**: файл, поле (`speed`/`vorticity`/`density`/`gamma`), палитра, масштаб, шаг кадра, - **анимация**: файл, поле (`speed`/`vorticity`/`density`/`gamma`), палитра, масштаб, шаг кадра,
частота, скорость воспроизведения, диапазон нормировки; частота, скорость воспроизведения, диапазон нормировки, усреднение k×k клеток в пиксель
- **вывод**: период живых строк, три уровня подробности, число окон в отчёте о сходимости, CSV. (`--gif-downsample`, без него кадр с сетки 4096×2048 неподъёмен);
- **вывод**: период живых строк, три уровня подробности, число окон в отчёте о сходимости, CSV
рядов с прореживанием (`--series-every`), x–t диаграмма осевой линии (`--xt`), метрики
эталонных течений (`--case-csv`) и машиночитаемая сводка всего прогона (`--summary`).
## Что печатает отчёт ## Что печатает отчёт
@@ -138,7 +156,7 @@ cargo test --release -- --include-ignored # плюс эталоны стат
его дословно: ρ, u → f^eq → s и s^eq → Δs = s − s^eq → **Δh = h − h^eq = f − f^eq − Δs** → его дословно: ρ, u → f^eq → s и s^eq → Δs = s − s^eq → **Δh = h − h^eq = f − f^eq − Δs** →
γ по замкнутой оценке → **f′ = f − β(2Δs + γΔh)**. γ по замкнутой оценке → **f′ = f − β(2Δs + γΔh)**.
Проверено тестами (`cargo test`, 22 быстрых + 4 длинных): Проверено тестами (`cargo test`, 29 быстрых + 4 длинных):
- **проектор Δs** совпадает с матричным `M⁻¹·D·M` в базисе натуральных моментов (6)–(7) до 1e-13 - **проектор Δs** совпадает с матричным `M⁻¹·D·M` в базисе натуральных моментов (6)–(7) до 1e-13
— для обоих составов сдвиговой части; идемпотентен, не несёт ни массы, ни импульса; — для обоих составов сдвиговой части; идемпотентен, не несёт ни массы, ни импульса;
@@ -151,7 +169,12 @@ cargo test --release -- --include-ignored # плюс эталоны стат
- **объёмная вязкость по ур. (57)**: ξ = ν при следе в сдвиговой части и ξ = c_s²(1/(γβ) − ½) - **объёмная вязкость по ур. (57)**: ξ = ν при следе в сдвиговой части и ξ = c_s²(1/(γβ) − ½)
без него; без него;
- равновесие в product-form сохраняет ρ и ρu до 1e-13; Zou–He ставит ровно заданные скорость - равновесие в product-form сохраняет ρ и ρu до 1e-13; Zou–He ставит ровно заданные скорость
на входе и плотность на выходе; SDF всех семи форм даёт верный знак и |∇φ| = 1 ± 0.05. на входе и плотность на выходе; SDF всех восьми форм даёт верный знак и |∇φ| = 1 ± 0.05
(у эллипса для этого пришлось считать ближайшую точку итеративно: дешёвое приближение давало
|∇φ| = 0.57 вдали от поверхности);
- **HRR-сборка сохраняет моменты**, ради которых затевалась: ρ, ρu и Π восстановленной функции
распределения совпадают с целевыми, а при нулевой скорости она совпадает с Градовой (все
коэффициенты 3-го порядка рекурсивно обращаются в ноль).
### Вихрь Тейлора–Грина: второй порядок сходимости (разд. VI) ### Вихрь Тейлора–Грина: второй порядок сходимости (разд. VI)
@@ -229,31 +252,45 @@ c_s²(1/(γβ) − ½) и, поскольку измеренная ⟨γ⟩ ≈
Ключ `--wall`: Ключ `--wall`:
- `bouzidi` (умолчание) — интерполированный отскок: доля пересечения q входит в КАЖДУЮ - `hrr` (умолчание) — восстановление по целевым моментам с **рекурсивной регуляризацией**
восстанавливаемую популяцию, полинково; (Malaspinas 2015; Coreixas и др., PRE 96, 033306): ряд Эрмита продолжен до 3-го порядка, а
- `grad` — условие Града (Dorschner, Bösch, Chikatamarla, Boulouchos, Karlin, JFM 801 (2016), коэффициенты 3-го порядка не считаются по популяциям, а выражаются через 2-й рекурсивно:
разд. 2.1 и прил. B): задаются не популяции, а целевые моменты — ρ, u и тензор давлений, — `a₃_xxy = 2u_x·a₂_xy + u_y·a₂_xx`, `a₃_xyy = 2u_y·a₂_xy + u_x·a₂_yy`. В D2Q9 `a₃_xxx` и
после чего недостающие популяции собираются приближением Града (2.13); `a₃_yyy` решёткой не поддерживаются и отбрасываются; множитель 1/2c_s⁶ (а не 1/6c_s⁶) учитывает
три перестановки индексов;
- `grad` — то же самое с обрывом ряда на тензоре давлений: условие Града (Dorschner, Bösch,
Chikatamarla, Boulouchos, Karlin, JFM 801 (2016), разд. 2.1 и прил. B). Задаются не популяции,
а целевые моменты — ρ, u и Π, — после чего недостающие популяции собираются приближением
Града (2.13);
- `bouzidi` — интерполированный отскок: доля пересечения q входит в КАЖДУЮ восстанавливаемую
популяцию, полинково;
- `staircase` — простой отскок, q игнорируется. Не для счёта: это база сравнения, показывающая, - `staircase` — простой отскок, q игнорируется. Не для счёта: это база сравнения, показывающая,
сколько именно даёт субсеточность. сколько именно даёт субсеточность.
Целевые моменты у `hrr` и `grad` одни и те же — (B 1) и (B 3) прил. B JFM 801; отличается только
то, до какого порядка восстанавливается функция распределения по этим моментам.
**Субсеточность — измеренная.** Прямой тест: сдвигаем тело внутри клетки и смотрим, насколько **Субсеточность — измеренная.** Прямой тест: сдвигаем тело внутри клетки и смотрим, насколько
поедет Cd. У по-настоящему субсеточной границы ответ не должен зависеть от того, где тело стоит поедет Cd. У по-настоящему субсеточной границы ответ не должен зависеть от того, где тело стоит
относительно узлов (Re = 20, D = 16, стационар, сдвиги 0…½ клетки): относительно узлов (Re = 20, D = 16, стационар, пять положений на полклетки, GPU):
| модель | разброс Cd | Cd | | модель | разброс Cd | Cd |
|---|---|---| |---|---|---|
| `staircase` | 1.11% | 2.68–2.71 | | `staircase` | 0.98% | 2.482–2.506 |
| `grad` | 0.64% | 2.64–2.66 | | `grad` | 0.62% | 2.449–2.464 |
| `bouzidi` | **0.19%** | 2.653–2.658 | | `hrr` | 0.61% | 2.450–2.464 |
| `bouzidi` | **0.14%** | 2.459–2.463 |
Град оказывается ровно между ступенькой и Bouzidi, и это следует из его устройства: положение **HRR не улучшает разрешение геометрии и не должен** — 0.61% против 0.62% у Града. Это следует
из устройства обеих схем: третий порядок Эрмита уточняет ВОССТАНОВЛЕНИЕ популяций по моментам, а
положение стенки входит в моментные схемы совсем другим местом. Обе моментные схемы оказываются
ровно между ступенькой и Bouzidi, и это тоже следует из их устройства: положение
стенки входит туда ТОЛЬКО через целевую скорость (B 1) — одну усреднённую по узлу величину. стенки входит туда ТОЛЬКО через целевую скорость (B 1) — одну усреднённую по узлу величину.
Целевая плотность (B 3) — обычная сумма отскочивших и известных популяций, без q вовсе; тензор Целевая плотность (B 3) — обычная сумма отскочивших и известных популяций, без q вовсе; тензор
давлений — конечные разности по решётке, тоже без q. Плюс все недостающие популяции узла давлений — конечные разности по решётке, тоже без q. Плюс все недостающие популяции узла
собираются из ОДНОГО набора моментов, так что полинковая направленность теряется. Bouzidi же собираются из ОДНОГО набора моментов, так что полинковая направленность теряется. Bouzidi же
подставляет свою q в каждую популяцию отдельно. Ступенчатой поверхность у Града не становится, подставляет свою q в каждую популяцию отдельно. Ступенчатой поверхность у моментных схем не
но геометрия у него разрешена заметно грубее. становится, но геометрия у них разрешена заметно грубее.
**Сходимость по разрешению тела.** Физическая постановка фиксирована (домен 15D × 10D, **Сходимость по разрешению тела.** Физическая постановка фиксирована (домен 15D × 10D,
блокировка 0.1, Re = 20), меняется только число клеток на диаметр: блокировка 0.1, Re = 20), меняется только число клеток на диаметр:
@@ -269,27 +306,67 @@ c_s²(1/(γβ) − ½) и, поскольку измеренная ⟨γ⟩ ≈
Для сравнения, `staircase` при D = 16 даёт 2.69 — то есть +6.7% к пределу, тогда как обе Для сравнения, `staircase` при D = 16 даёт 2.69 — то есть +6.7% к пределу, тогда как обе
субсеточные модели держатся в пределах +0.4%. субсеточные модели держатся в пределах +0.4%.
**Зачем тогда Град.** Его преимущество в статье — не геометрическая точность, а устойчивость **Зачем тогда моментные схемы.** Их преимущество в статье — не геометрическая точность, а
на турбулентных режимах (авторы пишут, что интерполяционные схемы «ограничены низкими числами устойчивость на турбулентных режимах (авторы пишут, что интерполяционные схемы «ограничены
Рейнольдса, поскольку на границе возникают паразитные скачки») и естественная форма для низкими числами Рейнольдса, поскольку на границе возникают паразитные скачки») и естественная
подвижных стенок: скорость стенки входит в целевые значения, а не отдельной поправкой. форма для подвижных стенок: скорость стенки входит в целевые значения, а не отдельной поправкой.
В здешней канальной постановке преимущества по устойчивости воспроизвести не удалось: при росте В здешней канальной постановке преимущества по устойчивости воспроизвести не удалось: при росте
Re обе модели теряют счёт на одном и том же значении (Re ≈ 5·10⁴ при теле в 16 клеток), то есть Re обе модели теряют счёт на одном и том же значении (Re ≈ 5·10⁴ при теле в 16 клеток), то есть
ограничивает не стенка, а что-то другое — вероятнее всего Zou–He при τ → ½. Поэтому умолчание — ограничивает не стенка, а что-то другое — вероятнее всего Zou–He при τ → ½.
`bouzidi`, а `grad` стоит держать в виду для будущих подвижных тел.
Ограничение реализации: `--wall grad` пока только на процессорном бэкенде; GPU при таком выборе **Умолчание — `hrr`, и это решение временное.** Оно принято по устройству схемы (третий порядок
отказывается запускаться явно, а не считает молча по Bouzidi. Эрмита фильтрует высокочастотный мусор у стенки, чего обрыв на Π не делает), а не по здешним
измерениям: на стационарном цилиндре при Re = 20 отличить `hrr` от `grad` нельзя вовсе. Вопрос
ставит ребром группа C кампании — там обе моментные схемы и Bouzidi гоняются на Re = 20, 150 и
2000. Если данные не подтвердят преимущества HRR на турбулентном режиме, умолчанием станет
`bouzidi`, у которого измеренное разрешение геометрии вчетверо лучше.
Все четыре модели работают на обоих бэкендах и совпадают между ними до 0.007% по Cd. Это
специально проверяется: раньше GPU при `--wall staircase` молча считал по Bouzidi, и обнаружилось
это только потому, что две модели дали побитово одинаковый результат там, где обязаны были
разойтись.
### Паритет бэкендов и согласованность уровней ### Паритет бэкендов и согласованность уровней
CPU (f64) и GPU (f32) на одной постановке совпадают до 4–5 значащих цифр шаг в шаг: ⟨ρ⟩ CPU (f64) и GPU (f32) на одной постановке совпадают до 4–5 значащих цифр шаг в шаг: ⟨ρ⟩
1.04933 против 1.04934, Cd 2.339 против 2.338, ⟨γ⟩ 1.2645 против 1.2646. На Intel Iris Xe GPU 1.04933 против 1.04934, Cd 2.339 против 2.338, ⟨γ⟩ 1.2645 против 1.2646. На Intel Iris Xe GPU
даёт ≈105 MLUPS против ≈18 MLUPS у процессора. даёт ≈195 MLUPS против ≈18 MLUPS у процессора.
Один и тот же случай с патчем ×2 и вовсе без измельчения (`--refine 1`) даёт St 0.1951 против Один и тот же случай с патчем ×2 и вовсе без измельчения (`--refine 1`) даёт St 0.1951 против
0.1970 и ⟨Cd⟩ 1.956 против 1.943 — связка уровней систематики не вносит. 0.1970 и ⟨Cd⟩ 1.956 против 1.943 — связка уровней систематики не вносит.
**Предел на размер сетки снят.** У GPU есть жёсткий предел `maxComputeWorkgroupsPerDimension`
= 65535, а диспетчеризация была одномерной: при 64 узлах на рабочую группу это упирало сетку в
4.2 миллиона узлов, то есть примерно 2048×2048. Всё, что крупнее, падало ошибкой валидации —
не считало медленно, а не запускалось вовсе. Теперь диспетчеризация двумерная, а линейный
индекс собирается в шейдере (`lin()`/`wlin()`); отображение «рабочая группа → узлы» при этом
остаётся ровно линейным, поэтому редукции ничего не заметили. Проверено до 4096×4096, паритет
с CPU не сдвинулся ни в одной цифре.
**Где именно кончается f32.** Прямой замер на Тейлоре–Грине, где ошибка известна точно:
| N | CPU, f64 | GPU, f32 |
|---|---|---|
| 64 | 9.83e-3 | 9.80e-3 |
| 128 | 2.40e-3 | 3.29e-3 |
| 256 | 5.97e-4 | 2.37e-2 |
Пока истинная ошибка выше ~10⁻³, f32 идёт с f64 вровень; ниже — промахивается на порядок и
больше. Практический вывод, заложенный в кампанию: исследования сходимости считаются на CPU,
всё остальное — на GPU. Двойной точности на GPU здесь быть не может в принципе: **в WGSL типа
`f64` не существует**, поэтому wgpu не выразит её ни на каком железе; локальная Iris Xe вдобавок
сообщает `shaderFloat64 = false`, а на потребительских NVIDIA f64 идёт в 1/64 от f32 — то есть
медленнее, чем CPU.
Что удалось выжать вместо точности — производительность. Два изменения:
1. **Батчинг чтения.** Раньше после каждого шага делался `map_async` + `poll(Wait)` ради 48 байт
статистики: на 240×120 счёт упирался в 863 шаг/с при том, что сам счёт занимал 0.27 мс из
1.16. Теперь итоги копятся в кольце на 128 слотов, синхронизация — раз в батч: **6715 шаг/с**,
в 7.8 раза быстрее, при неизменном пошаговом интерфейсе снаружи.
2. **Компенсированное суммирование** (Кэхена–Ноймайера) в редукциях и в сумме сил. Наивная сумма
по 10⁵–10⁷ узлам съедает ~log₂N бит мантиссы — именно там f32 терял основную точность.
### Обтекание цилиндра против литературы ### Обтекание цилиндра против литературы
Постановка 480×240, D = 24, Re = 150, блокировка β = D/Ny = 0.1, умолчания решателя: Постановка 480×240, D = 24, Re = 150, блокировка β = D/Ny = 0.1, умолчания решателя:
@@ -341,22 +418,101 @@ CPU (f64) и GPU (f32) на одной постановке совпадают
Старт из покоя (`--init rest`) разгоняет весь столб жидкости и закачивает моду; при разгоне Старт из покоя (`--init rest`) разгоняет весь столб жидкости и закачивает моду; при разгоне
за 1000 шагов против акустического пробега 831 шаг это для звука удар. за 1000 шагов против акустического пробега 831 шаг это для звука удар.
- **губка перед выходом включена и подобрана по домену** (`nx/12`, не меньше 16 столбцов, - **губка перед выходом включена и подобрана по домену** (`nx/12`, не меньше 16 столбцов,
с запасом до патча). Отключается `--sponge-len 0`. с запасом до патча). Отключается `--sponge-len 0`. Вклад у неё скромный — см. таблицу ниже,
— но она бесплатна для сил и снимает остаточную пульсацию примерно вдвое при утроении длины.
- **`--outlet extrapolate`** — нуль-градиент поперечной скорости; жёсткий ноль отражает вихри - **`--outlet extrapolate`** — нуль-градиент поперечной скорости; жёсткий ноль отражает вихри
дорожки обратно к телу. дорожки обратно к телу.
Эффект умолчаний на той же постановке: **Что именно помогает — разделено измерением** (480×240, D=24, Re=150, 30 000 шагов):
| | размах ⟨ρ⟩ | пульсация u′/U | | старт | губка, столбцов | пульсация u′/U | St | ⟨Cd⟩ | rms Cl |
|---|---|---| |---|---|---|---|---|---|
| старт из покоя, без губки | 0.1307 | **75%** | | uniform | 0 | 1.12% | 0.1839 | 1.483 | 0.404 |
| умолчания | 0.0017 | **1.0%** | | uniform | 40 | 1.03% | 0.1840 | 1.483 | 0.404 |
| uniform | 120 | 0.83% | 0.1839 | 1.482 | 0.403 |
| rest | 0 | **74.91%** | 0.1534 | 2.009 | 0.807 |
| rest | 40 | **74.90%** | 0.1539 | 2.010 | 0.808 |
| rest | 120 | **74.99%** | 0.1542 | 2.008 | 0.808 |
Заодно выправились и сами числа: St 0.183 вместо 0.194, rms Cl 0.38 вместо 0.84, ⟨ρ⟩ 1.0000. Читается однозначно: **весь эффект даёт однородный старт**, 74.9% → 1.12%, причём при
полностью выключенной губке. Губка снимает только остаток — 1.12% → 1.03% → 0.83%, — и на
силы с частотой схода не влияет вовсе (St 0.1839 во всех трёх строках).
**При старте из покоя губка не помогает совсем.** Это не осечка реализации, а свойство моды:
губка поднимает вязкость на последних столбцах, а там у стоячей четвертьволновой моды **узел
давления и пучность скорости** — то самое место, где повышенная вязкость её почти не трогает.
Бегущую волну такая губка съедает, стоячую — нет. Убрать моду можно только не возбуждая её.
Заодно видно, ЧЕМ платит неверный старт: St 0.153 вместо 0.184, ⟨Cd⟩ 2.01 вместо 1.48,
rms Cl 0.81 вместо 0.40 — то есть 75-процентная продольная пульсация ломает не косметику, а
все три величины, ради которых постановка и считается.
Отчёт печатает период моды, время её вязкого затухания и измеренную пульсацию в конце прогона, Отчёт печатает период моды, время её вязкого затухания и измеренную пульсацию в конце прогона,
с явным предупреждением, если она превысила 5% от U. с явным предупреждением, если она превысила 5% от U.
**Инструмент разбора — `--xt`.** Каждые `--xt-every` шагов пишется срез ⟨ρ⟩ и u_x вдоль осевой
линии, по строке на срез. Наклон полос на такой диаграмме прямо даёт скорость распространения:
звук (±c_s) или конвекция (U). Стоячие узлы видны как вертикальные линии постоянной фазы, и это
сразу отличает резонанс от неустойчивости самого граничного условия, привязанной к столбцу x = 0
и никуда не бегущей.
Есть и **губка после входа** — `--sponge-in <столбцов>`, по умолчанию выключена. Гасит продольные
волны до того, как они отразятся от входа-поршня. Осмысленна на высоких Re, где стартовая волна
перестаёт быть безобидной полоской; цена — искажение профиля прямо на входе, поэтому включать её
надо осознанно, а не «на всякий случай».
Что **не помогает** и оставлено только для повторной проверки — `--init-taper`. Замерено:
сглаживание стартовой скорости у тела давит возмущение плотности на первом шаге восьмикратно
(2.54·10⁻² → 3.13·10⁻³), но пик ЗА ПРОГОН при этом даже подрастает (до 3.22·10⁻²). Возмущение
просто переносится во времени: поток всё равно обязан разогнаться вокруг тела, и энергия этого
переходного процесса задана физикой, а не гладкостью начального поля. Умолчание — 0.
## Валидационная кампания
`bench/` — 115 прогонов на ≈90 часов GPU, разложенных по девяти группам: эталоны первоисточников,
цилиндр против литературы, модели стенки, профили крыла, сложная и множественная геометрия,
границы домена, старт и время жизни, внутренние инварианты, сверхмелкие сетки до 4096×2048.
Каждый прогон кладёт логи, ряды, машиночитаемую сводку и гифку на всю свою длительность в
собственную папку.
```sh
cd bench
python preflight.py # каждый сценарий стартует на два шага: ловит опечатки
./run_campaign.sh --calibrate # замерить MLUPS этой машины: оценки в часах иначе гадание
./run_campaign.sh --dry-run # смета: что, сколько шагов, сколько часов
./run_campaign.sh --resume # считать, пропуская уже готовое
```
Предполётную проверку стоит гонять всерьёз: она поймала, что вся группа сверхмелких сеток
падала на пределе GPU (см. ниже), а девять прогонов передавали `--body-x` дважды. Оба отказа
проявились бы только на сервере, часов через двадцать после старта кампании.
Подробности — в `bench/README.md`: раскладка выходных файлов, таблица групп, модель стоимости и
список того, что известно заранее (какие прогоны обязаны развалиться и почему).
## Развёртывание (Docker)
```sh
docker build -t kbc2d docs/theory/2d_solver
docker run --rm --gpus all kbc2d --calibrate # проверить, что GPU виден
docker run -d --gpus all -v "$PWD/out:/work/bench/out" kbc2d --resume
```
`ENTRYPOINT` — драйвер кампании, `CMD` по умолчанию `--dry-run`: случайный `docker run` покажет
смету и выйдет, а не запустит сточасовую задачу.
**Главная тонкость — Vulkan внутри контейнера.** NVIDIA Container Toolkit подкладывает
Vulkan-ICD (`nvidia_icd.json`) только если в `NVIDIA_DRIVER_CAPABILITIES` есть `graphics`;
с одним `compute` wgpu не увидит ни одного адаптера. В образе это прописано, но может быть
переопределено снаружи, поэтому `vulkan-tools` лежит внутрь: первым делом на сервере стоит
выполнить `docker run --rm --gpus all --entrypoint vulkaninfo kbc2d --summary`.
Проверено локально: образ собирается, кампания внутри него проходит смоук с монтированием
результатов на хост, физика совпадает с хостовой до последней цифры (ошибка Тейлора–Грина
9.829e-3 при N=64 и 2.401e-3 при N=128 — те же значения, что вне контейнера), а `--backend gpu`
без проброшенной карты отказывает явным сообщением, а не считает молча. GPU-путь в контейнере
проверяется только на машине с картой.
## Дальше ## Дальше
- σ·n-кросс-чек силы (интеграл тензора напряжений по контуру) как независимая проверка GMEM; - σ·n-кросс-чек силы (интеграл тензора напряжений по контуру) как независимая проверка GMEM;
@@ -365,4 +521,7 @@ CPU (f64) и GPU (f32) на одной постановке совпадают
стартовый импульс от появления тела в потоке; стартовый импульс от появления тела в потоке;
- подвижные и вращающиеся тела: GMEM уже записан в галилей-инвариантной форме и принимает - подвижные и вращающиеся тела: GMEM уже записан в галилей-инвариантной форме и принимает
скорость стенки на линке, но подача этой скорости не подключена; скорость стенки на линке, но подача этой скорости не подключена;
- несколько тел одновременно (сейчас — одно тело на выбор из семи форм). - разбор нерешённых 12% по Cd и предела устойчивости Re ≈ 5·10⁴ — обе задачи вынесены в
кампанию (группы B и G), выводы делать по её данным;
- больше четырёх тел в домене: сейчас сила считается по четырём вёдрам (`MAX_BODY_BUCKETS`),
геометрия при этом собирается из любого числа тел, но силы сверх четвёртого сливаются вместе.
+113
View File
@@ -0,0 +1,113 @@
# Валидационная кампания
115 прогонов, ≈90 часов на RTX 4070 Ti. Проверяет решатель по трём независимым линиям:
эталонам из статей авторов метода, литературе по обтеканию тел и внутренним инвариантам самой
схемы. Каждый прогон кладёт логи, ряды, машиночитаемую сводку и гифку в собственную папку.
## Быстрый старт на сервере
```sh
docker build -t kbc2d docs/theory/2d_solver
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
docker run --rm --gpus all --entrypoint vulkaninfo kbc2d --summary | head -20
```
Если адаптеров ноль — почти наверняка дело в `NVIDIA_DRIVER_CAPABILITIES`: Container Toolkit
подкладывает Vulkan-ICD только при наличии `graphics` в списке. В образе это прописано, но
может быть переопределено снаружи.
## Без Docker
```sh
cargo build --release # из docs/theory/2d_solver
cd bench
python preflight.py # каждый сценарий стартует на два шага
./run_campaign.sh --calibrate # замерить MLUPS этой машины
KBC2D_GPU_MLUPS=1400 ./run_campaign.sh --dry-run
./run_campaign.sh --resume
```
Под Windows то же самое делает `run_campaign.ps1` (тот же `scenarios.json`); он нужен для
локальной отладки обвязки, целевая машина — Linux.
## Ключи драйвера
| ключ | что делает |
|---|---|
| `--dry-run` | печатает смету: что, сколько шагов, сколько часов. Ничего не считает |
| `--calibrate` | три коротких прогона, замер фактических MLUPS этой машины |
| `--smoke` | три самых дешёвых прогона: проверить обвязку, а не физику |
| `--resume` | пропускает прогоны, у которых уже есть `summary.json` |
| `--group A,B` | только выбранные группы |
| `--only cyl_re150` | по подстроке идентификатора |
| `--budget-hours 24` | остановиться, когда время выйдет |
Прогон, который упал или развалился, помечается в сводке и **не останавливает кампанию**:
группы E и G специально ищут предел устойчивости, там развал — ожидаемый результат.
## Что где лежит
```
out/summary.csv сводная таблица: id, группа, статус, секунды, стоимость
out/campaign.log журнал с отметками времени
out/<id>/cmd.txt точная команда, которой прогон был запущен
out/<id>/log.txt полный вывод
out/<id>/report.txt только итоговый отчёт
out/<id>/series.csv временные ряды по шагам
out/<id>/summary.json ключевые метрики машиночитаемо — с этого удобно начинать разбор
out/<id>/*.gif анимация
out/<id>/*_case.csv энергия, энстрофия, палинстрофия (эталонные течения)
out/<id>/xt_*.csv x–t диаграммы (группа G)
```
## Группы
| | прогонов | что проверяется |
|---|---|---|
| **A** | 12 | эталоны первоисточников: Тейлор–Грин (второй порядок сходимости; при фиксированном u₀ — полка O(Ma²)), сдвиговый слой Re=3·10⁴, затухающая турбулентность |
| **B** | 16 | цилиндр против литературы: стационар Re=20/40, дорожка Re=100…300, разрешение D=16…128, блокировка Ny/D=6…32 с экстраполяцией к бесконечной среде |
| **C** | 20 | модели стенки и субсеточность: hrr / grad / bouzidi / staircase при Re=150 и 2000, плюс сдвиг тела внутри клетки на 0 / ¼ / ½ для всех четырёх |
| **D** | 14 | профили крыла: поляра NACA 0012, Re-серия, изгиб 4412, сходимость по хорде |
| **E** | 12 | сложная и множественная геометрия: тандем, решётка, перфорация, многоэлементный профиль, сверхтонкая пластина, клин, зазубренная кромка |
| **F** | 9 | поле влияния и границы домена: отступы до входа и выхода, вложенный патч |
| **G** | 14 | старт, акустика, время жизни: x–t диаграммы, губки, предел по Re, прогон на 10⁷ шагов |
| **H** | 9 | инварианты: симметрия, зеркальность, зависимость от числа Маха, расхождение f32 против f64 |
| **I** | 9 | сверхмелкие сетки 4096×2048 по всем формам и композиции из трёх тел |
## Стоимость и время
Стоимость каждого прогона хранится в **обновлениях узлов** (`nodes_per_step × steps`) — это
единственная мера, переносимая между машинами. Часы драйвер получает, поделив её на MLUPS.
Оценки по умолчанию исходят из 1200 MLUPS на GPU и 22 на CPU; **`--calibrate` обязателен**,
потому что эти числа взяты с другой машины.
Пересобрать список с другой длительностью:
```sh
python gen_scenarios.py --scale 3 # все прогоны в полтора раза длиннее (≈135 ч)
```
## Что известно заранее
- **Точность f32.** Замерено на Тейлоре–Грине: GPU совпадает с CPU/f64, пока истинная ошибка
выше ~10⁻³, и промахивается в 40 раз, когда она ниже. Поэтому исследования сходимости из
группы A идут на CPU — это указано в самих сценариях.
- **Развалы ожидаемы** в группе G (предел по Re) и у прогона `A09_shear_n512_lbgk`: LBGK при
Re=3·10⁴ обязан развалиться там, где KBC доживает — это и есть проверяемое утверждение.
- **Гифки** пишутся на всю длительность прогона, в реальном времени, 10 кадр/с. Число кадров
этим задано жёстко (у самого длинного прогона их 16 666), поэтому единственный рычаг —
размер кадра. Замерено: 0.103 байта на пиксель после LZW; отсюда бюджет
кадры×пиксели ≤ 1.5·10⁹ на гифку, но ширина не опускается ниже 480 пикселей. Итог по
кампании: **≈4.4 ГБ**, самый тяжёлый файл 226 МБ.
- **Перед запуском** имеет смысл прогнать предполётную проверку: каждый сценарий стартует на
два шага, что ловит опечатки в ключах и несовместимые сочетания до того, как кампания уйдёт
считать на девяносто часов. Именно она поймала, что вся группа I падала на пределе GPU в
65535 рабочих групп на измерение.
@@ -0,0 +1,518 @@
#!/usr/bin/env python3
# Генератор списка прогонов кампании -> scenarios.json
#
# Список порождается кодом, а не правится руками: серии по Re, по разрешению и по углу атаки
# получаются циклами, а стоимость каждого прогона считается тут же и суммируется. Запуск:
# python gen_scenarios.py # перезаписать scenarios.json и напечатать смету
# python gen_scenarios.py --scale 2 # растянуть все длительности вдвое
#
# Стоимость меряется в ОБНОВЛЕНИЯХ УЗЛОВ (nodes_per_step * steps) — единственная переносимая
# между машинами мера. Часы драйвер получает из неё, поделив на фактические MLUPS, которые
# замеряет на месте (--calibrate).
import argparse
import json
import math
import os
RUNS = []
U_LAT = 0.05 # решёточная скорость по умолчанию
DX = 0.1 # размер клетки, м (умолчание решателя)
U_PHYS = 30.0 # скорость потока, м/с (умолчание решателя)
GIF_MAX_W = 1280 # предельная ширина кадра после прореживания
GIF_MIN_W = 480 # ниже не опускаемся: нечитаемая гифка бесполезнее большой
GIF_PX_BUDGET = 1.5e9 # кадры×пиксели на гифку; при 0.103 байта на пиксель это ~150 МБ
def conv_steps(d_cells, n_conv, u_lat=U_LAT):
"""Сколько шагов нужно на n_conv конвективных времён D/U."""
return int(round(n_conv * d_cells / u_lat))
def add(rid, group, title, expect, args, nodes, steps, backend="gpu",
gif=None, nx=None, extra_out=()):
"""Записать прогон. `nodes` — обновлений узлов за шаг, `steps` — сколько шагов."""
a = list(args) + ["--steps", str(steps), "--backend", backend]
# ряды прореживаем так, чтобы их осталось порядка 200 тысяч записей
if steps > 400_000:
a += ["--series-every", str(max(1, steps // 200_000))]
if "case_csv" in extra_out:
a += ["--case-csv", f"{rid}_case.csv"]
if gif:
# Гифка идёт на всю длительность прогона в реальном времени, 10 кадр/с — число кадров
# задано физикой прогона и не обсуждается. Единственный рычаг — пиксели в кадре, и он
# нужен: замерено 0.103 байта на пиксель после LZW, так что прогон на 16 тысяч кадров
# при 960×576 весит 900 МБ, а такой файл уже нечем открыть. Поэтому кроме ограничения
# по ширине действует бюджет ПРОИЗВЕДЕНИЯ кадры×пиксели, но ширина не опускается ниже
# GIF_MIN_W: нечитаемая гифка бесполезнее большой.
ny = max(1, int(nodes) // max(1, nx or 1))
u_lat = float(a[a.index("--u-lat") + 1]) if "--u-lat" in a else U_LAT
frames = max(1, int(steps * (u_lat * DX / U_PHYS) * 10))
down = max(1, math.ceil((nx or 0) / GIF_MAX_W))
while nx and frames * (nx // down) * (ny // down) > GIF_PX_BUDGET \
and nx // (down + 1) >= GIF_MIN_W:
down += 1
a += ["--gif", gif, "--gif-field", "vorticity", "--gif-every", "auto",
"--gif-fps", "10", "--gif-speed", "1",
"--gif-downsample", str(down), "--gif-scale", "1"]
RUNS.append({
"id": rid, "group": group, "title": title, "expect": expect,
"backend": backend, "args": a,
"nodes_per_step": int(nodes), "steps": int(steps),
"cost": int(nodes) * int(steps),
"outputs": list(extra_out),
})
# ══════════════════════════════════════════════════════════════════════════════
# A. Эталоны первоисточников: периодические течения, тела и ГУ нет вовсе
# ══════════════════════════════════════════════════════════════════════════════
def group_a(scale):
NU = 0.0192
for n in (64, 128, 256, 512):
# диффузионное измельчение: nu фиксирована, u0 ~ 1/N, поэтому Re сохраняется, а
# число Маха падает вместе с сеткой — только так виден второй порядок целиком
u0 = 0.03 * 64 / n
tc = int(round(math.log(2.0) / (NU * (2 * math.pi / n) ** 2 * 17)))
add(f"A{len(RUNS)+1:02d}_tg_diff_n{n}", "A",
f"Тейлор–Грин, диффузионное измельчение, N={n} (CPU/f64)",
"порядок сходимости 2 по ряду N=64..512; ошибка на N=512 ниже 2e-4",
["--case", "taylor-green", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", f"{u0:.6f}", "--re", "100", "--case-every", str(max(1, tc // 20))],
n * n, tc, backend="cpu", extra_out=("case_csv",))
for n in (128, 256):
# при ФИКСИРОВАННОМ u0 ошибка упирается в полку O(Ma^2) и от сетки не зависит
tc = int(round(math.log(2.0) / (NU * (2 * math.pi / n) ** 2 * 17)))
add(f"A{len(RUNS)+1:02d}_tg_fixed_n{n}", "A",
f"Тейлор–Грин при фиксированном u₀, N={n} — демонстрация полки O(Ma²)",
"ошибка перестаёт падать с ростом N: это свойство слабо-сжимаемого метода",
["--case", "taylor-green", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.03", "--re", f"{0.03 * n / NU:.1f}",
"--case-every", str(max(1, tc // 20))],
n * n, tc, backend="cpu", extra_out=("case_csv",))
for n in (512,):
for op, km, tag in (("kbc", "n1", "kbc_n1"), ("kbc", "n2", "kbc_n2"), ("bgk", "n1", "lbgk")):
steps = int(4 * n / 0.04 * scale)
add(f"A{len(RUNS)+1:02d}_shear_n{n}_{tag}", "A",
f"Сдвиговый слой Re=3·10⁴, N={n}, {tag}",
"KBC доживает до конца, LBGK обязан развалиться (разд. VII статьи)",
["--case", "shear-layer", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.04", "--re", "30000", "--collision", op, "--kbc-model", km,
"--case-every", str(max(1, steps // 200))],
n * n, steps, gif=f"shear_{tag}.gif", nx=n, extra_out=("case_csv",))
for n, tag, ops in ((2048, "n2048", ("kbc", "bgk")), (4096, "n4096", ("kbc",))):
for op in ops:
steps = int(200_000 * scale * (2048 / n))
add(f"A{len(RUNS)+1:02d}_turb_{tag}_{op}", "A",
f"Затухающая турбулентность N={n}, {op}",
"энстрофия и палинстрофия падают монотонно; KBC держится там, где LBGK нет",
["--case", "decaying-turbulence", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.02", "--re", f"{0.02 * n / 8.16e-5:.0f}", "--collision", op,
"--case-every", str(max(1, steps // 300))],
n * n, steps, gif=f"turb_{tag}_{op}.gif", nx=n, extra_out=("case_csv",))
# ══════════════════════════════════════════════════════════════════════════════
# B. Цилиндр против литературы
# ══════════════════════════════════════════════════════════════════════════════
def flatten(d):
"""Словарь ключей в плоский argv. Именно словарь, а не список: clap отвергает
повторённый ключ, а сценарии сплошь и рядом переопределяют умолчания сборщика."""
a = []
for k, v in d.items():
a += [k, str(v)]
return a
def cyl_args(d, re, nx, ny, **kw):
a = {"--shape": "cylinder", "--size": d, "--nx": nx, "--ny": ny,
"--body-x": nx // 5, "--re": re, "--refine": 1}
a.update({"--" + k.replace("_", "-"): v for k, v in kw.items()})
return flatten(a)
def group_b(scale):
# стационарные режимы: дорожки нет, Cd — одно число
for re in (20, 40):
d, nx, ny = 48, 960, 576
st = int(conv_steps(d, 60) * scale)
add(f"B{len(RUNS)-len(RUNS)+len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_re{re}",
"B", f"Цилиндр Re={re}, стационар, D=48",
"Cd выходит на постоянную; сравнение с литературой по стационарному обтеканию",
cyl_args(d, re, nx, ny, pert_amp=0), nx * ny, st,
gif=f"cyl_re{re}.gif", nx=nx)
# дорожка Кармана: длинное осреднение, это главный заход на литературу
for re in (100, 150, 200, 300):
d, nx, ny = 64, 1280, 768
st = int(conv_steps(d, 1500) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_re{re}", "B",
f"Цилиндр Re={re}, дорожка Кармана, D=64, 1500 конв. времён",
"St≈0.183 и ⟨Cd⟩≈1.33 при Re=150 после поправки на блокировку",
cyl_args(d, re, nx, ny), nx * ny, st, gif=f"cyl_re{re}.gif", nx=nx)
# разрешение тела: сходимость Cd
for d in (16, 32, 64, 128):
nx, ny = 20 * d, 12 * d
st = int(conv_steps(d, 800) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_d{d}", "B",
f"Цилиндр Re=150, разрешение D={d}",
"Cd сходится по D; экстраполяция даёт сеточно-независимое значение",
cyl_args(d, 150, nx, ny), nx * ny, st, gif=f"cyl_d{d}.gif", nx=nx)
# блокировка канала: экстраполяция к бесконечной среде — прямой заход на нерешённые 12%
for k in (6, 8, 12, 16, 24, 32):
d, nx, ny = 48, 960, k * 48
st = int(conv_steps(d, 1000) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_block{k}", "B",
f"Цилиндр Re=150, блокировка Ny/D={k}",
"экстраполяция Cd и St к нулевой блокировке; ожидание — сходимость к 1.33 и 0.183",
cyl_args(d, 150, nx, ny), nx * ny, st, gif=None, nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# C. Модели стенки и субсеточность
# ══════════════════════════════════════════════════════════════════════════════
def group_c(scale):
for wall in ("hrr", "grad", "bouzidi", "staircase"):
for re in (150, 2000):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 400 if re > 20 else 60) * scale)
add(f"C{len([r for r in RUNS if r['group']=='C'])+1:02d}_wall_{wall}_re{re}", "C",
f"Модель стенки {wall}, Re={re}",
"все субсеточные модели обязаны сойтись к одному пределу; staircase — база",
cyl_args(d, re, nx, ny, wall=wall), nx * ny, st,
gif=f"wall_{wall}_re{re}.gif" if re == 150 else None, nx=nx)
# Чувствительность к положению тела ВНУТРИ клетки — прямая проверка субсеточности, и
# единственное измерение, которое вообще разделяет модели стенки. Двух положений для этого
# мало: по двум точкам не отличить систематический разброс от совпадения, поэтому берём
# четверти, и все четыре модели. Прогоны короткие (стационар при Re=20), вся серия — минуты.
for wall in ("hrr", "grad", "bouzidi", "staircase"):
for off in (0.0, 0.25, 0.5):
d, nx, ny = 16, 320, 192
st = int(conv_steps(d, 60) * scale)
add(f"C{len([r for r in RUNS if r['group']=='C'])+1:02d}_sub_{wall}_{int(off*100):02d}",
"C", f"Субклеточный сдвиг тела {off} клетки, стенка {wall}",
"разброс Cd по сдвигам: чем субсеточнее модель, тем он меньше",
cyl_args(d, 20, nx, ny, body_y=96 + off, wall=wall, pert_amp=0),
nx * ny, st)
# ══════════════════════════════════════════════════════════════════════════════
# D. Профили крыла
# ══════════════════════════════════════════════════════════════════════════════
def foil_args(chord, alpha, re, nx, ny, naca="0012", **kw):
a = {"--shape": "naca", "--naca": naca, "--size": chord,
"--body-angle": alpha, "--nx": nx, "--ny": ny,
"--body-x": nx // 4, "--re": re, "--refine": 1}
a.update({"--" + k.replace("_", "-"): v for k, v in kw.items()})
return flatten(a)
def group_d(scale):
# поляра: наклон Cl(alpha) и положение сваливания
for al in (0, 4, 8, 12, 16):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca0012_a{al:02d}", "D",
f"NACA 0012, α={al}°, Re=1000, хорда 128",
"линейный участок Cl(α) и срыв; сравнение с низкорейнольдсовой литературой",
foil_args(c, al, 1000, nx, ny), nx * ny, st,
gif=f"naca0012_a{al:02d}.gif", nx=nx)
for re in (500, 2000, 10000):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca0012_re{re}", "D",
f"NACA 0012, α=8°, Re={re}",
"перестройка следа с ростом Re; проверка устойчивости на профиле",
foil_args(c, 8, re, nx, ny), nx * ny, st,
gif=f"naca0012_re{re}.gif", nx=nx)
for al in (0, 6, 12):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca4412_a{al:02d}", "D",
f"NACA 4412 (с изгибом), α={al}°, Re=1000",
"ненулевой Cl при α=0 — прямая проверка средней линии профиля",
foil_args(c, al, 1000, nx, ny, naca="4412"), nx * ny, st,
gif=f"naca4412_a{al:02d}.gif", nx=nx)
for c in (48, 96, 192):
nx, ny = 16 * c, 8 * c
st = int(conv_steps(c, 300) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca_chord{c}", "D",
f"NACA 0012, α=8°, сходимость по хорде {c}",
"Cl и Cd сходятся по разрешению хорды",
foil_args(c, 8, 1000, nx, ny), nx * ny, st, nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# E. Сложная и множественная геометрия
# ══════════════════════════════════════════════════════════════════════════════
def group_e(scale):
# тандем: литература даёт переключение режимов около L/D 3.5–4
for ld in (1.5, 3.0, 5.0):
d, nx, ny = 48, 1440, 720
gap = int(ld * d)
bodies = f"cylinder:d={d},x=360,y=360; cylinder:d={d},x={360 + gap},y=360"
st = int(conv_steps(d, 800) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_tandem_ld{int(ld*10)}", "E",
f"Тандем цилиндров, зазор L/D={ld}",
"при малом зазоре у заднего тела ОТРИЦАТЕЛЬНОЕ сопротивление; переход около 3.5–4",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "150", "--refine", "1"],
nx * ny, st, gif=f"tandem_ld{int(ld*10)}.gif", nx=nx)
# решётка как пористая среда
for step_d, tag in ((3, "sparse"), (2, "dense")):
d, nx, ny = 24, 1440, 720
bodies = "; ".join(
f"cylinder:d={d},x={360 + i * step_d * d},y={360 + (j - 1.5) * step_d * d}"
for i in range(4) for j in range(4))
st = int(conv_steps(d, 600) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_array_{tag}", "E",
f"Решётка 4×4 цилиндров, шаг {step_d}D — пористая среда",
"суммарное сопротивление и структура течения сквозь набор тел",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "150", "--refine", "1"],
nx * ny, st, gif=f"array_{tag}.gif", nx=nx)
# перфорированная пластина: набор коротких пластин со щелями
for open_frac, tag in ((0.2, "open20"), (0.5, "open50")):
nx, ny = 1200, 600
seg, gap = 40, int(40 * open_frac / (1 - open_frac))
bodies = "; ".join(
f"plate:d={seg},t=0.12,a=90,x=300,y={60 + k * (seg + gap)}"
for k in range(max(1, (ny - 120) // (seg + gap))))
st = int(conv_steps(seg, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_perf_{tag}", "E",
f"Перфорированная пластина, скважность {open_frac}",
"струи в щелях и общее сопротивление; проверка множества тонких тел",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif=f"perf_{tag}.gif", nx=nx)
# многоэлементный профиль: основной + предкрылок
nx, ny = 2048, 1024
bodies = ("naca:naca=4412,d=200,a=6,x=560,y=512; "
"naca:naca=2412,d=70,a=18,x=430,y=470")
st = int(conv_steps(200, 300) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_multielem", "E",
"Многоэлементный профиль: основной 4412 плюс предкрылок",
"щель между элементами и раздельные Cl/Cd по телам",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "2000", "--refine", "1"],
nx * ny, st, gif="multielem.gif", nx=nx)
# сверхтонкая пластина: предел субсеточности, тело тоньше клетки
for t, tag in ((0.02, "t05"), (0.01, "t025")):
nx, ny = 1200, 600
st = int(conv_steps(50, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_thin_{tag}", "E",
f"Сверхтонкая пластина, толщина {t*50:.2f} клетки",
"предел субсеточной границы: тело тоньше клетки; ждём откаты на простой отскок",
["--shape", "plate", "--size", "50", "--thickness", str(t), "--body-angle", "20",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif=f"thin_{tag}.gif", nx=nx)
# клин и зазубренная кромка — субклеточная деталь на многоугольнике
nx, ny = 1200, 600
st = int(conv_steps(60, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_wedge", "E",
"Клин остриём против потока", "острая кромка: особая точка геометрии",
["--shape", "polygon", "--poly=-30,-18;36,0;-30,18", "--size", "60",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif="wedge.gif", nx=nx)
saw = ";".join(f"{-30 + 12 * k},{(-1) ** k * 6}" for k in range(6)) + ";30,-18;-30,-18"
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_serrated", "E",
"Зазубренная задняя кромка", "субклеточные зубцы: как их видит граница",
["--shape", "polygon", f"--poly={saw}", "--size", "60",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif="serrated.gif", nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# F. Поле влияния и границы домена
# ══════════════════════════════════════════════════════════════════════════════
def group_f(scale):
d = 48
for up in (2, 6, 16):
nx, ny = int((up + 20) * d), 12 * d
st = int(conv_steps(d, 600) * scale)
add(f"F{len([r for r in RUNS if r['group']=='F'])+1:02d}_inlet{up}d", "F",
f"Отступ до входа {up}D",
"с какого отступа результат перестаёт зависеть от положения входа",
cyl_args(d, 150, nx, ny, body_x=up * d), nx * ny, st)
for down in (5, 15, 40):
nx, ny = int((6 + down) * d), 12 * d
st = int(conv_steps(d, 600) * scale)
add(f"F{len([r for r in RUNS if r['group']=='F'])+1:02d}_outlet{down}d", "F",
f"Отступ до выхода {down}D",
"с какой длины следа выход перестаёт влиять на силы",
cyl_args(d, 150, nx, ny, body_x=6 * d), nx * ny, st)
for r in (1, 2, 3):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 600) * scale)
nodes = nx * ny
if r > 1:
ax, bx = int(nx // 5 - 1.5 * d), int(nx // 5 + 6.25 * d)
ay, by = int(ny / 2 - 2 * d), int(ny / 2 + 2 * d)
nodes += r * (r * (bx - ax) + 1) * (r * (by - ay) + 1)
add(f"F{len([r_ for r_ in RUNS if r_['group']=='F'])+1:02d}_refine{r}", "F",
f"Вложенный патч ×{r}",
"вносит ли связка уровней систематику: Cd и St обязаны совпасть",
cyl_args(d, 150, nx, ny, refine=r), nodes, st)
# ══════════════════════════════════════════════════════════════════════════════
# G. Старт, акустика, время жизни
# ══════════════════════════════════════════════════════════════════════════════
def group_g(scale):
d, nx, ny = 48, 960, 576
for re in (150, 2000, 20000):
for init in ("uniform", "rest"):
st = int(conv_steps(d, 300) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_xt_re{re}_{init}", "G",
f"x–t диаграмма, Re={re}, старт {init}",
"откуда идёт стартовое возмущение и раскачивается ли продольная мода",
cyl_args(d, re, nx, ny, init=init, sponge_len=0,
xt=f"xt_re{re}_{init}.csv", xt_every=200),
nx * ny, st, gif=f"xt_re{re}_{init}.gif", nx=nx, extra_out=("xt",))
for sp in (0, 40):
st = int(conv_steps(d, 400) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_sponge{sp}", "G",
f"Губка выхода {sp} столбцов при Re=5000",
"губка — единственное, что реально ест продольную моду",
cyl_args(d, 5000, nx, ny, sponge_len=sp), nx * ny, st)
for sp in (0, 40):
st = int(conv_steps(d, 400) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_spongein{sp}", "G",
f"Губка входа {sp} столбцов при Re=5000",
"помогает ли гасить волну до отражения от входа",
cyl_args(d, 5000, nx, ny, sponge_in=sp), nx * ny, st)
for re in (10000, 50000, 200000):
st = int(conv_steps(d, 200) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_limit_re{re}", "G",
f"Предел устойчивости, Re={re}",
"где именно гибнет счёт; часть этих прогонов обязана развалиться",
cyl_args(d, re, nx, ny), nx * ny, st)
# тест на время жизни: один очень длинный прогон
st = int(5_000_000 * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_lifetime", "G",
"Тест на время жизни: 5·10⁶ шагов",
"не уплывает ли ⟨ρ⟩ и не деградирует ли решение на очень длинной дистанции",
cyl_args(d, 150, nx, ny), nx * ny, st, gif="lifetime.gif", nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# H. Инварианты и точность
# ══════════════════════════════════════════════════════════════════════════════
def group_h(scale):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 400) * scale)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_sym_cyl", "H",
"Симметрия: цилиндр при α=0 без возмущения",
"Cl и Cm обязаны остаться ≈0 — это внутреннее свойство схемы, не литература",
cyl_args(d, 40, nx, ny, pert_amp=0), nx * ny, st)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_sym_foil", "H",
"Симметрия: NACA 0012 при α=0 без возмущения",
"Cl ≈ 0 у симметричного профиля под нулевым углом",
foil_args(64, 0, 500, 1024, 512, pert_amp=0), 1024 * 512,
int(conv_steps(64, 300) * scale))
for al, tag in ((8, "plus"), (-8, "minus")):
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_mirror_{tag}", "H",
f"Зеркальность: NACA 0012 при α={al}°",
"ряды обязаны зеркалиться: Cl меняет знак, Cd совпадает",
foil_args(64, al, 500, 1024, 512), 1024 * 512,
int(conv_steps(64, 300) * scale))
for ul in (0.02, 0.05, 0.1):
st2 = int(conv_steps(d, 400, ul) * scale)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_mach{int(ul*100):02d}", "H",
f"Зависимость от числа Маха, u_lat={ul}",
"результат обязан сходиться при Ma→0; расхождение и есть сжимаемостная ошибка",
cyl_args(d, 150, nx, ny, u_lat=ul), nx * ny, st2)
# прямой замер расхождения f32 и f64 от длины прогона
for bk in ("cpu", "gpu"):
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_prec_{bk}", "H",
f"Точность: одинаковая постановка на {bk}",
"расхождение f32 против f64 в зависимости от длины прогона",
cyl_args(d, 150, nx, ny), nx * ny, int(conv_steps(d, 800) * scale), backend=bk)
# ══════════════════════════════════════════════════════════════════════════════
# I. Сверхмелкие сетки по всем формам
# ══════════════════════════════════════════════════════════════════════════════
def group_i(scale):
shapes = [
("cylinder", ["--shape", "cylinder"]),
("square", ["--shape", "square"]),
("diamond", ["--shape", "diamond"]),
("ellipse", ["--shape", "ellipse", "--thickness", "0.35"]),
("naca", ["--shape", "naca", "--naca", "0012", "--body-angle", "8"]),
("triangle", ["--shape", "triangle"]),
("plate", ["--shape", "plate", "--thickness", "0.08", "--body-angle", "25"]),
("wedge", ["--shape", "polygon", "--poly=-128,-72;154,0;-128,72"]),
]
d, nx, ny = 256, 4096, 2048
st = int(conv_steps(d, 300) * scale)
for tag, extra in shapes:
add(f"I{len([r for r in RUNS if r['group']=='I'])+1:02d}_fine_{tag}", "I",
f"Сверхмелкая сетка 4096×2048, {tag}, D=256",
"держится ли субсеточная граница на предельном разрешении для этой формы",
extra + ["--size", str(d), "--nx", str(nx), "--ny", str(ny),
"--body-x", "820", "--re", "1000", "--refine", "1"],
nx * ny, st, gif=f"fine_{tag}.gif", nx=nx)
bodies = ("cylinder:d=200,x=800,y=1024; cylinder:d=200,x=1400,y=1024; "
"naca:naca=4412,d=300,a=10,x=2200,y=1024")
add(f"I{len([r for r in RUNS if r['group']=='I'])+1:02d}_fine_multi", "I",
"Сверхмелкая сетка, композиция из трёх тел",
"взаимодействие следов на предельном разрешении",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "1000", "--refine", "1"],
nx * ny, st, gif="fine_multi.gif", nx=nx)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--scale", type=float, default=2.0,
help="общий множитель длительности всех прогонов")
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "scenarios.json"))
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):
g(args.scale)
doc = {
"meta": {
"note": "Стоимость в обновлениях узлов — единственная переносимая между машинами "
"мера. Часы драйвер получает, поделив её на фактические MLUPS, которые "
"замеряет на месте (--calibrate).",
"gif_policy": "вся длительность прогона, реальная скорость, 10 кадр/с; кадр "
"прореживается до ширины не больше 1280 пикселей",
"scale": args.scale,
"assumed_gpu_mlups": 1200,
"assumed_cpu_mlups": 22,
},
"runs": RUNS,
}
with open(args.out, "w", encoding="utf-8") as f:
json.dump(doc, f, ensure_ascii=False, indent=1)
print(f"прогонов: {len(RUNS)} файл: {args.out}\n")
print(f"{'гр':>3}{'прогонов':>10}{'обновлений узлов':>20}{'часов GPU':>12}{'часов CPU':>12}")
tot_gpu = tot_cpu = 0.0
for g in sorted({r["group"] for r in RUNS}):
rs = [r for r in RUNS if r["group"] == g]
cost = sum(r["cost"] for r in rs)
hg = sum(r["cost"] / 1.2e9 / 3600 for r in rs if r["backend"] == "gpu")
hc = sum(r["cost"] / 2.2e7 / 3600 for r in rs if r["backend"] == "cpu")
tot_gpu += hg
tot_cpu += hc
print(f"{g:>3}{len(rs):>10}{cost:>20.3e}{hg:>12.1f}{hc:>12.1f}")
print(f"{'—':>3}{len(RUNS):>10}{sum(r['cost'] for r in RUNS):>20.3e}"
f"{tot_gpu:>12.1f}{tot_cpu:>12.1f}")
print(f"\nвсего ≈ {tot_gpu + tot_cpu:.1f} ч при 1200 MLUPS на GPU и 22 на CPU")
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
# Предполётная проверка кампании: каждый сценарий запускается на два шага.
#
# python preflight.py # проверить все
# python preflight.py --group I # только группу
#
# Ловит опечатки в ключах, несовместимые сочетания и пределы железа ДО того, как кампания
# уйдёт считать на девяносто часов. Не бесплатная привычка, а окупившаяся: именно так
# выяснилось, что вся группа сверхмелких сеток падала на пределе GPU в 65535 рабочих групп
# на измерение, а девять прогонов группы F передавали --body-x дважды.
#
# Физику проверка не трогает вовсе: два шага — это ровно «запустилось и не упало».
import argparse
import json
import os
import subprocess
import sys
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 ""))
# Всё, что пишет файлы, выкидываем: проверяем запуск, а не вывод. Ключ идёт со значением,
# поэтому следующий за ним аргумент тоже пропускается.
DROP = {"--gif", "--xt", "--case-csv", "--gif-field", "--gif-downsample", "--gif-scale",
"--gif-every", "--gif-fps", "--series-every"}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--group", help="только эта группа, например I")
ap.add_argument("--scenarios", default=os.path.join(HERE, "scenarios.json"))
args = ap.parse_args()
runs = json.load(open(args.scenarios, encoding="utf-8"))["runs"]
if args.group:
runs = [r for r in runs if r["group"].upper() == args.group.upper()]
if not runs:
print("под выборку не попал ни один прогон")
return 0
if not os.path.exists(BIN):
print(f"не найден бинарь решателя: {BIN}")
return 1
bad = []
for i, r in enumerate(runs, 1):
argv, skip = [], False
for a in r["args"]:
if skip:
skip = False
continue
if a in DROP:
skip = True
continue
argv.append(a)
if "--steps" in argv:
argv[argv.index("--steps") + 1] = "2"
else:
argv += ["--steps", "2"]
p = subprocess.run([BIN] + argv + ["--verbose", "quiet", "--report-every", "0"],
capture_output=True, cwd=HERE)
ok = p.returncode == 0
print("." if ok else "X", end="", flush=True)
if not ok:
tail = (p.stdout + p.stderr).decode("utf-8", "replace").strip().splitlines()
bad.append((r["id"], tail[-1] if tail else "(без вывода)"))
if i % 40 == 0:
print(f" {i}/{len(runs)}", flush=True)
print(f"\n\nпроверено сценариев: {len(runs)}, не запустились: {len(bad)}")
for id_, msg in bad:
print(f" {id_:<28} {msg}")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,108 @@
# Драйвер валидационной кампании для Windows. Делает ровно то же, что run_campaign.sh, и
# читает тот же scenarios.json — целевая машина Linux, а этот вариант нужен для локальной
# отладки обвязки.
#
# .\run_campaign.ps1 -DryRun
# .\run_campaign.ps1 -Calibrate
# .\run_campaign.ps1 -Smoke
# .\run_campaign.ps1 -Resume -Group A,B
# .\run_campaign.ps1 -Only cyl_re150 -BudgetHours 6
[CmdletBinding()]
param(
[switch]$DryRun,
[switch]$Resume,
[switch]$Calibrate,
[switch]$Smoke,
[string[]]$Group,
[string]$Only,
[double]$BudgetHours = 0,
[string]$Bin = "..\target\release\kbc2d.exe",
[string]$Scenarios = "scenarios.json",
[string]$Out = "out",
[double]$GpuMlups = 1200,
[double]$CpuMlups = 22
)
Set-Location $PSScriptRoot
if (-not (Test-Path $Bin)) { throw "не найден бинарь решателя: $Bin (собери cargo build --release)" }
if (-not (Test-Path $Scenarios)) { throw "не найден список сценариев: $Scenarios" }
if ($Calibrate) {
"Калибровка на этой машине (три коротких прогона)…"
foreach ($c in @(@(240,120,'gpu'), @(960,480,'gpu'), @(480,240,'cpu'))) {
$o = & $Bin --nx $c[0] --ny $c[1] --size 16 --refine 1 --steps 3000 `
--report-every 3000 --verbose full --backend $c[2] 2>$null
$m = ($o | Select-String 'MLUPS' | Select-Object -Last 1)
" $($c[0])x$($c[1]) на $($c[2]): $(if ($m) { ($m -replace '.*?([0-9.]+) MLUPS.*','$1') } else { 'не измерено' })"
}
""
"Подставь замеренное: .\run_campaign.ps1 -DryRun -GpuMlups <число> -CpuMlups <число>"
return
}
$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 ($Only) { $runs = $runs | Where-Object { $_.id -like "*$Only*" } }
if ($Smoke) { $runs = $runs | Sort-Object cost | Select-Object -First 3 }
if (-not $runs) { "под выборку не попал ни один прогон"; return }
$totalCost = ($runs | Measure-Object -Property cost -Sum).Sum
$estH = $totalCost / ($GpuMlups * 1e6) / 3600
"прогонов: $($runs.Count) обновлений узлов: {0:e3} ≈ {1:F1} ч при $GpuMlups MLUPS" -f $totalCost, $estH
"результаты: $Out\<id>\"
""
if ($DryRun) {
"{0,-28} {1,-3} {2,-4} {3,12} {4,8} {5}" -f 'id','гр','бэк','шагов','часов','название'
foreach ($r in $runs) {
$m = if ($r.backend -eq 'cpu') { $CpuMlups } else { $GpuMlups }
"{0,-28} {1,-3} {2,-4} {3,12} {4,8:F2} {5}" -f $r.id, $r.group, $r.backend, $r.steps, ($r.cost/($m*1e6)/3600), $r.title
}
return
}
New-Item -ItemType Directory -Force $Out | Out-Null
$summary = Join-Path $Out 'summary.csv'
$log = Join-Path $Out 'campaign.log'
if (-not (Test-Path $summary)) { 'id,group,backend,status,seconds,steps,cost,title' | Out-File $summary -Encoding utf8 }
$started = Get-Date
foreach ($r in $runs) {
$dir = Join-Path $Out $r.id
if ($Resume -and (Test-Path (Join-Path $dir 'summary.json'))) { "· $($r.id) — уже посчитан, пропускаю"; continue }
if ($BudgetHours -gt 0 -and ((Get-Date) - $started).TotalHours -ge $BudgetHours) {
"бюджет $BudgetHours ч исчерпан, останавливаюсь"; break
}
New-Item -ItemType Directory -Force $dir | Out-Null
# относительные имена файлов из сценария кладём внутрь папки прогона
$argv = @()
for ($i = 0; $i -lt $r.args.Count; $i++) {
$v = $r.args[$i]
if ($i -gt 0 -and @('--gif','--xt','--case-csv') -contains $r.args[$i-1]) { $v = Join-Path $dir $v }
$argv += $v
}
$argv += @('--summary', (Join-Path $dir 'summary.json'),
'--csv', (Join-Path $dir 'series.csv'),
'--verbose','full','--report-every','5000')
"$Bin $($argv -join ' ')" | Out-File (Join-Path $dir 'cmd.txt') -Encoding utf8
"▶ $($r.id) ($($r.group), $($r.backend)) $($r.title)"
$t0 = Get-Date
& $Bin @argv 2>&1 | Out-File (Join-Path $dir 'log.txt') -Encoding utf8
$st = if ($LASTEXITCODE -eq 0) { 'ok' } else { 'fail' }
$dt = [int]((Get-Date) - $t0).TotalSeconds
if (Select-String -Path (Join-Path $dir 'log.txt') -Pattern 'РАЗВАЛИЛСЯ' -Quiet) { $st = 'blewup' }
$rep = Get-Content (Join-Path $dir 'log.txt') -Encoding utf8
$k = ($rep | Select-String 'ИТОГОВЫЙ ОТЧЁТ' | Select-Object -First 1).LineNumber
if ($k) { $rep[($k-1)..($rep.Count-1)] | Out-File (Join-Path $dir 'report.txt') -Encoding utf8 }
'{0},{1},{2},{3},{4},{5},{6},"{7}"' -f $r.id,$r.group,$r.backend,$st,$dt,$r.steps,$r.cost,$r.title |
Out-File $summary -Append -Encoding utf8
"$(Get-Date -Format o) $($r.id) $st ${dt}s" | Out-File $log -Append -Encoding utf8
" → $st за $dt с"
}
""
"готово. сводная таблица: $summary"
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Драйвер валидационной кампании. Читает scenarios.json, гоняет прогоны по одному, кладёт
# логи, ряды, сводки и гифки каждого в собственную папку и ведёт общую таблицу.
#
# ./run_campaign.sh --dry-run смета: что и сколько будет считаться
# ./run_campaign.sh --calibrate замерить фактические MLUPS этой машины
# ./run_campaign.sh --smoke три коротких прогона: проверить обвязку
# ./run_campaign.sh --resume считать, пропуская уже готовое
# ./run_campaign.sh --group A,B только выбранные группы
# ./run_campaign.sh --only cyl_re150 по подстроке идентификатора
# ./run_campaign.sh --budget-hours 24 остановиться, когда время выйдет
#
# Прогон, который упал или развалился, помечается в сводке и НЕ останавливает кампанию:
# группы E и G специально ищут предел устойчивости.
set -u
cd "$(dirname "$0")"
BIN="${KBC2D_BIN:-../target/release/kbc2d}"
SCEN="${KBC2D_SCENARIOS:-scenarios.json}"
OUT="${KBC2D_OUT:-out}"
# ВНИМАНИЕ: имя GROUPS занято самим bash (список групп пользователя), присваивание в неё
# молча игнорируется, а "$GROUPS" под root разворачивается в 0 — и выборка съедает всю
# кампанию, не сказав ни слова. Отсюда GRP_SEL.
DRY=0; RESUME=0; CALIB=0; SMOKE=0; GRP_SEL=""; ONLY=""; BUDGET=""
GPU_MLUPS="${KBC2D_GPU_MLUPS:-1200}"
CPU_MLUPS="${KBC2D_CPU_MLUPS:-22}"
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY=1 ;;
--resume) RESUME=1 ;;
--calibrate) CALIB=1 ;;
--smoke) SMOKE=1 ;;
--group) GRP_SEL="$2"; shift ;;
--only) ONLY="$2"; shift ;;
--budget-hours) BUDGET="$2"; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "неизвестный ключ: $1" >&2; exit 2 ;;
esac
shift
done
command -v python3 >/dev/null 2>&1 && PY=python3 || PY=python
[ -x "$BIN" ] || { echo "не найден бинарь решателя: $BIN (собери cargo build --release)" >&2; exit 1; }
[ -f "$SCEN" ] || { echo "не найден список сценариев: $SCEN" >&2; exit 1; }
# ── калибровка: три коротких прогона на месте, чтобы оценки в часах были не гаданием ──
if [ "$CALIB" = 1 ]; then
echo "Калибровка на этой машине (три коротких прогона)…"
for spec in "240 120 gpu" "960 480 gpu" "480 240 cpu"; do
set -- $spec
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)
echo " $1x$2 на $3: ${m:-не измерено}"
done
echo
echo "Подставь замеренное в переменные окружения и запусти смету:"
echo " KBC2D_GPU_MLUPS=<число> KBC2D_CPU_MLUPS=<число> ./run_campaign.sh --dry-run"
exit 0
fi
mkdir -p "$OUT"
SUMMARY="$OUT/summary.csv"
LOG="$OUT/campaign.log"
[ -f "$SUMMARY" ] || echo "id,group,backend,status,seconds,steps,cost,title" > "$SUMMARY"
# Выборку и порядок считает python: разбирать JSON башем — верный способ ошибиться.
PLAN=$("$PY" - "$SCEN" "$GRP_SEL" "$ONLY" "$SMOKE" <<'PYEOF'
import json, sys
scen, groups, only, smoke = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1"
runs = json.load(open(scen, encoding="utf-8"))["runs"]
if groups:
keep = {g.strip().upper() for g in groups.split(",")}
runs = [r for r in runs if r["group"].upper() in keep]
if only:
runs = [r for r in runs if only in r["id"]]
if smoke:
# три самых дешёвых прогона: обвязку проверяем, а не физику
runs = sorted(runs, key=lambda r: r["cost"])[:3]
for r in runs:
print("\t".join([r["id"], r["group"], r["backend"], str(r["cost"]),
str(r["steps"]), r["title"], "\x1f".join(r["args"])]))
PYEOF
)
[ -n "$PLAN" ] || { echo "под выборку не попал ни один прогон"; exit 0; }
total_cost=0; n=0
while IFS=$'\t' read -r id grp bk cost steps title args; do
n=$((n + 1)); total_cost=$((total_cost + cost))
done <<< "$PLAN"
est_h=$("$PY" -c "
import sys
print('%.1f' % (float(sys.argv[1]) / (float(sys.argv[2]) * 1e6) / 3600))" "$total_cost" "$GPU_MLUPS")
echo "прогонов: $n обновлений узлов: $total_cost ≈ $est_h ч при $GPU_MLUPS MLUPS"
echo "результаты: $OUT/<id>/"
echo
if [ "$DRY" = 1 ]; then
printf '%-28s %-3s %-4s %12s %10s %s\n' id гр бэк шагов часов название
while IFS=$'\t' read -r id grp bk cost steps title args; do
m=$GPU_MLUPS; [ "$bk" = cpu ] && m=$CPU_MLUPS
h=$("$PY" -c "import sys;print('%.2f'%(float(sys.argv[1])/(float(sys.argv[2])*1e6)/3600))" "$cost" "$m")
printf '%-28s %-3s %-4s %12s %10s %s\n' "$id" "$grp" "$bk" "$steps" "$h" "$title"
done <<< "$PLAN"
exit 0
fi
started=$(date +%s)
while IFS=$'\t' read -r id grp bk cost steps title args; do
dir="$OUT/$id"
if [ "$RESUME" = 1 ] && [ -f "$dir/summary.json" ]; then
echo "· $id — уже посчитан, пропускаю"
continue
fi
if [ -n "$BUDGET" ]; then
spent=$(( ($(date +%s) - started) ))
limit=$("$PY" -c "import sys;print(int(float(sys.argv[1])*3600))" "$BUDGET")
[ "$spent" -ge "$limit" ] && { echo "бюджет $BUDGET ч исчерпан, останавливаюсь"; break; }
fi
mkdir -p "$dir"
# аргументы разделены символом 0x1f: в них есть пробелы (список тел) и запятые
IFS=$'\x1f' read -r -a ARGV <<< "$args"
ARGV+=(--summary "$dir/summary.json" --csv "$dir/series.csv" --verbose full --report-every 5000)
# относительные имена файлов из сценария кладём внутрь папки прогона
for i in "${!ARGV[@]}"; do
case "${ARGV[$((i-1))]:-}" in
--gif|--xt|--case-csv) ARGV[$i]="$dir/${ARGV[$i]}" ;;
esac
done
printf '%s\n' "$BIN ${ARGV[*]}" > "$dir/cmd.txt"
echo "▶ $id ($grp, $bk) $title"
t0=$(date +%s)
if "$BIN" "${ARGV[@]}" > "$dir/log.txt" 2>&1; then st=ok; else st=fail; fi
t1=$(date +%s); dt=$((t1 - t0))
grep -q "РАЗВАЛИЛСЯ" "$dir/log.txt" 2>/dev/null && st=blewup
sed -n '/ИТОГОВЫЙ ОТЧЁТ/,$p' "$dir/log.txt" > "$dir/report.txt" 2>/dev/null
printf '%s,%s,%s,%s,%s,%s,%s,"%s"\n' "$id" "$grp" "$bk" "$st" "$dt" "$steps" "$cost" "$title" >> "$SUMMARY"
echo "$(date -Is) $id $st ${dt}s" >> "$LOG"
echo " → $st за ${dt} с"
done <<< "$PLAN"
echo
echo "готово. сводная таблица: $SUMMARY"
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
# Запуск кампании на сервере с NVIDIA одной командой:
# docker compose run --rm kbc2d --calibrate # сначала убедиться, что GPU виден
# docker compose up -d # кампания в фоне
# docker compose logs -f # смотреть ход
#
# Результаты складываются в ./out на хосте.
services:
kbc2d:
build: .
image: kbc2d
container_name: kbc2d
volumes:
- ./out:/work/bench/out
environment:
# Без graphics NVIDIA Container Toolkit не подложит Vulkan-ICD и wgpu не увидит карту.
NVIDIA_DRIVER_CAPABILITIES: compute,utility,graphics
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
# Кампания идёт десятки часов и переживает перезапуск: --resume пропускает готовое.
command: ["--resume"]
restart: "no"