Массив популяций занимает 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>
18 KiB
Валидационная кампания
115 прогонов, ≈90 часов на RTX 4070 Ti. Проверяет решатель по трём независимым линиям: эталонам из статей авторов метода, литературе по обтеканию тел и внутренним инвариантам самой схемы. Каждый прогон кладёт логи, ряды, машиночитаемую сводку и гифку в собственную папку.
Быстрый старт на сервере
Образ опубликован, собирать ничего не нужно: notbigghost/kbc2d:1.2.0. Исходники на
сервере тоже не нужны — переносится один файл docker-compose.server.yml.
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
Порядок не случайный: узнать, что карта не видна, лучше на первом шаге, чем через час счёта.
Замеренные калибровкой числа подставляются переменными окружения — они влияют только на оценки в часах, не на счёт:
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 это прописано, но может быть переопределено снаружи.
Проверить, что хост вообще умеет отдавать карту:
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.
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 показывает худший случай.
Своя сборка образа
Если нужен образ из текущего состояния репозитория, а не опубликованный:
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
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 обязателен,
потому что эти числа взяты с другой машины.
Пересобрать список с другой длительностью:
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 рабочих групп на измерение.