Модель воздуха на GPU: строительные блоки (AM-02)
План — docs/plan/air_model.md (AM-02, AM-03), решатель — docs/archive/plan/wind-field.md → «Решатель на
GPU», WF-02/WF-03. Сетка — декартова с маской «под землёй» (решение AM-01,
tools/research/air3d/reference.md). Код — scripts/atmosphere/air_model/.
Состав
| Файл | Что |
|---|---|
air_gpu.gd (AirGpu) | локальный RD, ядра, буферы, запись запусков, программы, все блоки |
air_multigrid.gd (AirMultigrid) | V-цикл давления на сетке с маской (как MG прикидки) |
air_gpu_job.gd (AirGpuJob) | каркас расчёта порциями: start / poll / is_done / release, failed |
air_poisson_job.gd (AirPoissonJob) | пример наследника: давление V-циклами до невязки |
air_vec.glsl | поэлементные: fill, copy, scale, axpy, xpay, axpby, mul |
air_reduce.glsl | сумма, max|x|, Σx·y — деревом, два прохода, без атомиков |
air_stencil.glsl | 7-точечный шаблон: r = b − C·x, y = C·x |
air_line.glsl | прогонки по линиям в разделяемой памяти; упакованные системы; грубый уровень одной группой |
air_line_mp.glsl | многопроходная прогонка для линий > 1024 |
air_mg.glsl | шаблон из граней, огрубление граней и маски, ограничение, продолжение |
Эталоны — tools/research/air3d/gpu_block_refs.py (numpy, float64 по формулам ядер и прикидки) →
tests/atmosphere/fixtures/air_model/blocks/*.bin + .json (f32 LE, ~2,5 МБ). Тесты —
tests/atmosphere/test_air_gpu_blocks.gd (GPU, tools/gpu_tests.sh --filter=test_air_gpu),
tests/atmosphere/test_air_gpu_headless.gd (headless: без RD — понятная ошибка).
Замеры — AIR_GPU_BENCH=1 tools/gpu_tests.sh --filter=test_air_gpu_blocks (под
flock /tmp/heat_ca_gpu.lock).
Раскладка буферов
- Все буферы — storage float32. Поле (NZ, NY, NX): индекс
(k·NY + j)·NX + i(x быстрее всех, как массивы прикидки без ореола). - Шаблон C — 7 плоскостей по N: 0 центр, 1 −x, 2 +x, 3 −y, 4 +y, 5 −z, 6 +z; уравнение Σ C·x = b. Сосед за краем массива отсутствует (ядра проверяют границы по осям).
- Грани давления (проводимости K/h², 0 — закрыта): cx (NZ, NY, NX+1), cy (NZ, NY+1, NX), cz (NZ+1, NY, NX); маска act (NZ, NY, NX), 1 — воздух.
- Скаляры — буфер
AirGpu.scalars(64 числа): результаты редукций; множители поэлементных ядер могут браться оттуда (a = f·S[i]/S[j]) — CG/центрирование без чтения на CPU. - Push-константы у всех ядер одинаковые:
ivec4 i0; ivec4 i1; vec4 f;(48 байт). - Один буфер не привязывать в набор дважды, если его пишут: граф RD Godot 4.7 тогда теряет запись и не ставит барьер между запусками (так ломалась зебра «на месте»). Зебра поэтому пишет в тот же привязанный X, а «все линии» — в отдельный XO.
Блоки
- Поэлементные — цикл по всей сетке с шагом (число групп ≤ 65535, результат от него не зависит).
- Редукции — проход 0: G = min(1024, ⌈n/256⌉) групп, поток складывает t, t+T, … по порядку, дерево в разделяемой памяти → P[G]; проход 1: одна группа сворачивает P → S[out]. Порядок сложения задан только n → повтор даёт те же биты.
- Прогонка по линии (
AirGpu.line,zebra) — группа 256 потоков, LPG = 256/TPL линий длины n ≤ 1024/LPG целиком в разделяемой памяти (22,5 КБ). Метод разбиения (PaScaL_TDMA): поток сводит свой отрезок (≥ 2 точек) модифицированной прогонкой к двум строкам, сведённая система (2·TPL на линию) — параллельной циклической редукцией, затем внутренние точки. Строка — какline_thomasприкидки: соседи не на линии — из текущего x. Зебра — чётность суммы двух других индексов, проходы «чётные, затем нечётные»; «все линии» (Якоби) — выход в отдельный буфер. Загрузка сливается: для линий x подряд идут точки линии, для y/z — соседние линии. - Многопроходная прогонка (линия > 1024): тот же метод на уровне сетки — поток на отрезок (S = min(512, n/2) отрезков), сведённые системы 2S ≤ 1024 решает то же ядро в разделяемой памяти (упакованный режим), третий проход — внутренние точки. Длина линии не ограничена.
- Грубый уровень одной группой (
zebra_one_group, MODE 3): все проходы sweeps × (z, x, y) × 2 чётности внутри одного запуска с барьером группы — вместо 6·sweeps запусков на сетке 3×3×48. - V-цикл (
AirMultigrid) — какMGприкидки: шаблон из граней, неактивная клетка «1·φ = 0», огрубление только по x, y, пока NX, NY чётные и ≥ 6 (грубые грани: сумма двух / 8, по z — среднее 4, закрытие у неактивных), ограничение — среднее 2×2 × маска, продолжение — кусочно-постоянное × маска × corr, сглаживание — pre/post зебра-прогонки по z, на грубом — 20 × (z, x, y). Уровни строятся на GPU. Оператор сменяемый: наследник переопределяет_level_stencil/_coarsen(V-цикл работает с любыми 7-точечными шаблонами уровней). При закрытых гранях (Нейман) правая часть и φ центрируются по активным клеткам (DOT с маской + axpy со скаляром из буфера) —AirPoissonJob.
Порции и API каркаса
var job := AirPoissonJob.new() # или свой наследник AirGpuJob
job.chunk_ms = 30.0; job.timeout_s = 60.0
job.failed.connect(...) # "нет RenderingDevice…", "ошибка компиляции …", "таймаут …"
if job.start(): # RD, ядра, буферы (_setup); false — error, failed
# раз в кадр:
var p := job.poll() # 0..1
if job.is_done(): ...
job.release() # free() у RefCounted в GDScript занят — release()Наследник: _setup() -> bool, _step_program(i) -> Array (программа шага — gpu.record(fn),
записывается один раз), _record_check() (после шага: невязка → скаляр), _after_sync() -> bool
(читает только скаляры), _total_steps() -> int.
- poll(): если порция отправлена и по оценке готова —
sync(), GPU-время порции по меткамcapture_timestamp, проверка — только если порция кончилась на границе шага; затем запись следующей порции иsubmit(). - Порция — сколько запусков ядер влезает в бюджет по цене (AM-03: вес запуска =
AirGpu.LAUNCH_WEIGHT+ группы × вес ядра; по порциям уточняется «мс на единицу веса», скользящее с запасом вверх): бюджет = min(0,8·chunk_ms, 0,8·промежуток от конца poll до следующего poll;frame_gap_limit = false— без второго). На быстрой карте порция — целые шаги (кончается на границе шага), на медленной шаг режется между кадрами. Первая порция —first_chunk_weight, дальше рост не больше чем вдвое за порцию;max_steps_per_chunk— не больше стольких шагов (Пикар: 1).poll_slice(ms)— порции подряд в пределах ms за кадр (экран загрузки),run_blocking()— всё подряд (инструменты). - Главный поток не ждёт:
submit()сразу отдаёт работу GPU (проверено: sync после 80 мс паузы — 0,05 мс), к следующему poll порция уже готова. - Программы (
AirGpu.record/run) нужны из-за цены записи на GDScript: ~24 мкс на запуск при сборке каждый раз и ещё ~3–4 мкс на запуск вsubmit()(граф RD); V-цикл 96² с грубым уровнем запусками — 180 запусков, запись дороже самого счёта на GPU.
Замеры (RTX 4070 SUPER)
Точность (test_blocks_vs_reference, отн. ошибка max|gpu − эталон| / max|эталон|; у сумм — к Σ|·|;
V-цикл — после вычитания среднего по активным клеткам: φ при закрытых гранях определена с точностью
до постоянной):
| Блок | Размер | Ошибка |
|---|---|---|
| axpy / xpay / axpby / scale / mul | 15 360 | 8,8e-8 / 6,7e-8 / 7,0e-8 / 0 / 0 |
| сумма (x > 0), сумма (±), max|x|, dot | 15 360 | 3,8e-8, 7,4e-10, 1e-16, 1,1e-9 |
| сумма, dot (n не кратно 256) | 12 345 | 1,6e-9, 1,4e-10 |
| resid7 / apply7 | 23×20×17 | 1,1e-7 / 1,1e-7 |
| прогонка x, y, z: зебра и «все линии» (в разделяемой памяти) | 23×20×17 | 2,0e-7 … 3,2e-7 |
| прогонка x, n = 1000 (TPL 256, одна линия на группу) | 1000×2×1 | 1,9e-7 |
| многопроходная прогонка x, n = 2500 / y, n = 1300 | 2500×2×1 / 2×1300×1 | 1,7e-7 / 1,9e-7 |
| шаблон уровня 0 / 1 из граней | 32×32×16 / 16×16×16 | 1,5e-8 / 7,5e-9 |
| V-цикл ×1 / ×5 (маска с горой, Нейман) | 32×32×16 | 3,9e-6 / 1,3e-6 (без центрирования 3–5e-6) |
Два прогона (прогонки, редукции, 3 V-цикла) — побитно одинаковы (test_two_runs_bitwise_equal).
Время блоков (GPU-метки, 20 повторов, мс; ГБ/с — минимальный трафик: axpy 12 Б/клетку, dot 8, resid7 и прогонки по направлению (обе чётности) 40). Разброс между прогонами — до ×2 на 192² (общая карта; ниже — лучший из двух прогонов):
| Блок | 96×96×48 | 192×192×48 | 64×64×112 |
|---|---|---|---|
| axpy | 0,004 (1300–1500*) | 0,009 (2250*) | 0,004 (1370*) |
| dot, 2 прохода | 0,008 (460*) | 0,011 (1250*) | 0,008 (460*) |
| resid7 | 0,011 (1600*) | 0,17 (430) | 0,010 (1900*) |
| прогонка z, зебра | 0,039 (450*) | 0,47–0,68 (100–150) | 0,049 (370*) |
| прогонка x, зебра | 0,032 (550*) | 0,18–0,22 (330–400) | 0,033 (560*) |
| прогонка y, зебра | 0,040 (440*) | 0,77–0,87 (80–90) | 0,040 (460*) |
| прогонка x, все линии | 0,027 (660*) | 0,19–0,43 (170–380) | 0,028 (650*) |
| V-цикл (уровни; запусков) | 1,23–1,36 (6; 61) | 3,5–7,0 (7; 73) | 1,67–1,88 (5; 49) |
| V-цикл, грубый уровень запусками | 1,7 (180) | 3,6–3,7 (192) | 1,6–2,0 (168) |
* — данные целиком в L2 (48 МБ у 4070 SUPER: 96² и 64²×112 ≈ 20 МБ), это не память, а кэш. На 192² (шаблон 50 МБ) — настоящая DRAM (~500 ГБ/с паспорт): axpy/dot всё ещё из L2 (2 массива 14 МБ), resid7 — ~85 % полосы, прогонки z/y — 20–30 %: зебра по z/y читает через клетку (линии одной чётности идут с шагом 2 по x) — сектор 32 Б используется наполовину, и второй проход уже не застаёт данные в L2. Прогонка x (линии подряд в памяти) — 65–80 %. Это главное место для ускорения в AM-03 (варианты: волновой обход обеих чётностей плитками, чтобы вторая шла из L2; хранение шаблона в fp16; шаблон на лету из полей вместо 7 плоскостей).
V-цикл на 96² и 64²×112 упирается не в память, а в число запусков и барьеров (61 запуск по
~10–20 мкс на малых уровнях); грубый уровень одной группой (MODE 3) даёт выигрыш на 96² (1,2–1,4
против 1,7 мс), на 192² и 64²×112 — вровень с запусками (там переключатель
coarse_one_group_max). Сходимость V-циклов на синтетике с горой: ~×20 за цикл (эталон: невязка
1,9e-2 → 1,0e-3 → 4,4e-5 → 2,1e-6 → 1,1e-7); пол невязки в float32 ~1e-5 от max|f|.
Порции (test_job_chunks, AirPoissonJob до невязки 1e-4, окно 320×240):
| Случай | V-циклов | Порций | Макс. порция GPU | Макс. ожидание в sync |
|---|---|---|---|---|
| 96×96×48, бюджет 30 мс | 22 | 2 | 25,1 мс | 0,03 мс |
| 192×192×48, бюджет 30 мс | 3 | 5 | 2,9 мс | 4,9 мс (первая порция) |
| 192×192×48, бюджет 2 мс (V-цикл режется) | 3 | 7 | 2,7 мс | 4,6 мс (первая порция) |
poll() зовёт sync() только когда с submit() прошло не меньше ожидаемого GPU-времени порции, иначе просто возвращается — ожидание остаётся лишь у первой порции (подготовка уровней + шаг без оценки цены). В настоящей игре poll — раз в кадр; в тестовом раннере кадры неровные (1–40 мс).
Цена записи на CPU: сборка запуска на GDScript ~24 мкс, submit() ~3–4 мкс на запуск (граф RD);
программа (record/run) убирает первое.
Переносимость
- GLSL 450 без расширений, без subgroup-операций; группы ≤ 256 потоков; разделяемая память ≤ 22,5 КБ; push-константы 48 байт; > 65535 групп — сетка 2D (прогонки считают плоский номер группы).
- lavapipe (llvmpipe 19.1.7, Mesa, программный Vulkan на CPU;
VK_DRIVER_FILES=…/lvp_icd.json tools/gpu_tests.sh --filter=test_air_gpu): все блоки против эталона в допуске (прогонки ≤ 3,2e-7, V-цикл ×1/×5 — 1,3e-6/8,8e-7), два прогона побитно одинаковы, задача давления сходится; порции30 мс (один запуск ядра на 192×192×48 — до ~0,6 с на CPU), проверка времени порции на llvmpipe пропускается. RADV — AMD-карты нет, не проверено.
Пикар (AM-03): один уровень
Итерация Пикара масштаба 1 на GPU по спецификации эталона AM-01
(tools/research/air3d/reference.md → «Дискретизация», исполнение — air.py). Код:
| Файл | Что |
|---|---|
air_case.gd (AirCase) | вход решения: сетка, рельеф hc, dθ̄/dz по уровням, z_i, поток тепла H, ветер; prepare() — всё по столбцам и уровням (K_b-замыкание по столбцам с гауссом, нагрев по слою перемешивания, губки, баланс «жёстких» границ) — O(NX·NY + NZ) на CPU |
air_place.gd (AirPlace) | вход для места игры: блочное среднее слоя detail → область 38,4 км, погода игры на час (WeatherModel.diurnal_state → θ̄(z), z_i, как weather.py), солнце по склонам с водой (air.solar_flux) |
air_picard.glsl | ядра (варианты): setup (поклеточно: типы клеток/граней, фон и граница ветра, губки, K_b, Q, проводимости проекции), mgfaces (грани и маска V-цикла), bc, kloc, mom, heat, div, proj, resid |
air_picard_job.gd (AirPicardJob) | задача порциями (AirGpuJob): старт, итерации с проверкой, finalize, решение без нагрева → w_mech, выход в WindField |
API
var c := AirPlace.domain_case(detail, water_img, loc_cfg, 400.0, 12.0, 3.0, 150.0) # место, час, ветер
# или вручную: AirCase.new(); set_grid(...); hc, gam, z_i, heat, u10, wdir
var job := AirPicardJob.new()
job.case = c
job.mech = true # сначала то же без нагрева (H = 0) → w_mech (Стык 1↔2)
job.warm = old.state() # по желанию: тёплый старт {u, v, w, th, thd, p} (та же сетка)
job.finished.connect(...); job.failed.connect(...)
job.start() # false — error; дальше раз в кадр job.poll()
var f := job.field() # WindField (docs/guide/air-model.md → «Поле на CPU»): u, v, θ′ — с нагревом,
# w_mech — без нагрева, w_conv = w − w_mech
var st := job.state() # для следующего тёплого старта
job.results # по решениям: status (ok / max), iters, hist невязок, div_rms/max
job.release()Вход AirCase: set_grid(dx, nx, ny, dz, z_bot, nz, x0, y0), hc (ny·nx), gam (NZ = nz + 2,
в центрах уровней с ореолом), z_i (м над морем, NAN — нет конвекции), heat (ny·nx, Вт/м²;
пусто — без нагрева), u10, wdir (откуда), taper (гасить нагрев у края), p (параметры
эталона Params).
Порядок программы
Шаг задачи — фаза, порция — не больше одного шага (max_steps_per_chunk = 1; шаг дороже
бюджета режется между кадрами):
- Подготовка (в
start, без ожидания): загрузка столбцов/уровней/параметров, ядроsetup,mgfaces×4, уровни V-цикла (AirMultigrid.build), K = K_h = K_b. - Старт:
bc(фон) + проекция 30 V-циклами без изменения p; тёплый —bc+ 4 V-цикла. - 10 итераций + проверка (как
Air.solve): итерация —bc,kloc,momu/v/w, 2 × (зебра z, x, y по u, v, w), проекция (div→ Σ → минус среднее → φ = 0 → V-цикл → φ минус среднее →proj, p += φ),heat(режим 0 — θ′_d), 4 × зебра z, x, y по θ′_d,heat(режим 1 — полное θ′, источник −θ′_d/τ от нового θ′_d), 4 × зебра по θ′ (А1, C1 v2). Проверка:bc, шаблоны от текущего состояния,residпо неизвестным → Σr² и max|r| (редукции), тепло θ′_d (слоты 22, 23) и θ′, ∇·u → 10 скаляров. После порции CPU читает скаляры: СКО импульса < 2e-5, тепла (большая из θ′ и θ′_d) < 5e-7, ∇·u < 1e-6 → finalize; нечисло или max > 50 → «разошёлся» (failed); 3000 итераций → статус «max» и finalize. - finalize:
bc+ 10 V-циклов без изменения p + ∇·u в скаляры. - При
mech— сначала весь цикл без нагрева, w копируется вwmech, затем столбцы случая с нагревом и снова 2–4 (грани V-цикла общие: губки и θ̄ те же). Решение без нагрева целиком (um, vm, wmech, thm, thdm, pm) — дляstate(true)и родителя окна без нагрева.
Буферы (N = NX·NY·NZ с ореолом, float32)
tcode (типы: cell + 4·tu + 16·tv + 64·tw), ubu/ubv/ubw (фон ветра на гранях-неизвестных и
заданное значение на граничных), thb, spu/spw/spc (губки), kbg (K_b), Q, Kx/Ky/Kz (проводимости
SIMPLEC), u, v, w, th, thd (θ′_d — диабатическая часть, А1), thbd (ореол/цель губки θ′_d), p, nu,
nuh, r, wmech — 24·N (+ um, vm, thm, thdm, pm при паре); шаблоны Cu, Cv, Cw — строкой на точку
(C0..C6, b — 8 чисел, 3·8·N; шаблоны тепла θ′_d и θ′ — по очереди в Cu: импульс к тому времени
решён). 1/Pr_t — слот prm[20] (AirCase.P_IPRT). Грани V-цикла,
act, rhs, φ — по внутренним клеткам. Итого ~46·N·4 Б + уровни V-цикла: 400 м (98×98×52)
~95 МБ, 200 м (194×194×52) ~370 МБ. Параметры — буфер prm (32 числа), столбцы — col
(12 плоскостей NY·NX), уровни — lev (5 × NZ).
Шаблон строкой на точку (air_line.glsl, константа PACKED; AM-02 предлагал «хранение
шаблона»): зебра по z и y читает линии через одну, и в плоской раскладке (7 плоскостей + b) сектор
32 Б каждой из 8 плоскостей используется наполовину. Строкой — вся строка в одном секторе. На
200 м: зебра z 2,26 → 1,32 мс, y 2,67 → 1,61, x 1,33 → 1,07, тепло 3,64 → 2,78 мс на итерацию;
итерация 15 → 11,2 мс. Плоская раскладка (V-цикл, блоки AM-02) не менялась.
Сверка с эталоном AM-01
tests/atmosphere/test_air_picard_gpu.gd (tools/gpu_tests.sh --filter=test_air_picard; замеры и тёплый старт — test_air_picard_bench.gd),
tests/atmosphere/test_air_place.gd (без GPU).
| Сверка | Результат |
|---|---|
блоки одной итерации (4 случая ref/: подготовка, граничные условия, kloc, шаблоны, прогонки, шаг импульса, ∇·u, центрирование, V-цикл, проекция, шаблон и шаг тепла) | ≤ 7,4e-7 отн. (V-цикл — после вычитания среднего); типы клеток/граней — точно |
∇·u* против div_star эталона | flat_wind 1,5e-3 — пол округления входа до f32 (∇·u* ~1e-7 1/с, 6e-8·|u|/Δx); против f64 от тех же f32-входов ≤ 7e-7 |
| решение до критерия: flat_wind / agnesi / heated_slope / saddle | итераций 10/630/60/320 — как эталон; max|Δu|/|u₀| ≤ 1,2e-6, max|Δθ′| ≤ 4,8e-7 К; СКО ∇·u 1e-10…2e-9 (эталон 1e-10…3,5e-9) |
Онгудай 400 м, 12:00, 3 м/с с 150° (fixtures/air_model/picard/, эталон f64) | без нагрева 100 итераций (эталон 101), с нагревом 90 (91) (после А1 — 150 (151) и 130 (131); поле у старта ≤ 2e-4·|u₀|, θ′ ≤ 7,6e-4 К): эталон с графом CUDA делает одну разгонную итерацию до счёта (проверки на 11, 21, …); поле у старта (16×16×50) max|Δ| ≤ 7,5e-4 м/с (1,1e-4·|u₀|), θ′ ≤ 7,2e-4 К |
Каянча, выборка WindField (field_async) на 10/50/150/400 м над землёй | u, v, w_mech, w_conv, θ′ — как эталон to_game_field.game_sample до 2e-4 м/с |
| баланс тепла (Air.heat_budget) | невязка 2,5e-4 отн. (эталон 2,6e-4); после А1 (выхолаживание Σ θ′_d/τ) — 4,5e-4 (эталон 4,45e-4) |
| два прогона (saddle) | побитно одинаковы, итерации те же |
| тёплый старт (Онгудай 400 м, 3 м/с) | то же поле — 10 итераций (первая проверка); 12:00 → 12:30 — 40 итераций против 90 с холодного (−56 %) |
вход места AirPlace против real.py/weather.py | hc 1,2e-4 м, H 2,4e-5 Вт/м², dθ̄/dz 7e-11 К/м, z_i точно; подготовка на CPU 0,54 (400 м) / 0,59 с (200 м) |
Ошибка в эталоне (исправлена AM-01, fe27a5d): air.py передавал в ядро kloc массив λ
(lamc) в F-порядке — ядро читало λ транспонированной. Видно на heated_slope (kloc 1e-4 отн.) и
на всех прогретых решениях Онгудая (λ 112–256 м, |λ − λᵀ| до 65 м).
Замеры (RTX 4070 SUPER, под flock /tmp/heat_ca_gpu.lock)
AIR_PICARD_BENCH=1 tools/gpu_tests.sh --filter=test_air_picard_bench (окно 320×240;
AIR_PICARD_BENCH_DX=200, AIR_PICARD_BENCH_QUICK=1 — меньше случаев). Онгудай, 12:00, ветер с
150°, Δτ_u = 0,3·Δx (эталон cf64b7b).
Разбивка итерации по ядрам (мс, GPU-метки, 3 м/с после 20 итераций):
| Ядро | 400 м (98×98×52) | 200 м (194×194×52) | запусков |
|---|---|---|---|
| граничные условия | 0,006 | 0,016 | 1 |
| местное K (kloc) | 0,017 | 0,11 | 1 |
| шаблоны импульса ×3 | 0,13 | 0,84 | 3 |
| прогонки импульса z (2 × u, v, w) | 0,42 | 1,37 | 12 |
| прогонки импульса x | 0,28 | 1,11 | 12 |
| прогонки импульса y | 0,37 | 1,68 | 12 |
| шаблон тепла | 0,05 | 0,29 | 1 |
| прогонки тепла z, x, y ×4 | 0,83 | 2,91 | 24 |
| V-цикл (6 / 7 уровней) | 1,15 | 2,86 | 61 / 73 |
| ∇·u, центрирование, поправка | 0,01 | 0,20 | 9 |
| итерация целиком | 3,3 | 11,2 | 136 / 148 |
| проверка (раз в 10 итераций) | 0,40 | 1,98 | 19 |
400 м — данные в L2 (48 МБ), время — запуски и барьеры (V-цикл: 61 запуск на 1,15 мс); 200 м — DRAM, прогонки ~60 %, V-цикл 25 %.
Решения целиком (с нагревом; «пара» — без нагрева + с нагревом, как для игры). GPU — сумма порций по меткам; стена — от start() до готовности в окне тестов: «кадры» — poll() раз в кадр (игра идёт), «загрузка» — poll_slice(40 мс) (экран загрузки):
| Сетка, ветер | итераций (эталон f32) | GPU, с | мс/итер. | стена, кадры, с | стена, загрузка, с | макс. порция, мс |
|---|---|---|---|---|---|---|
| 400 м, штиль | 60 (61) | 0,25 | 4,2 | 0,97 | — | 11,6 |
| 400 м, 3 м/с | 90 (91) | 0,35 | 3,9 | 1,12 | 0,96 | 11,7 / 24,0 |
| 400 м, 6 м/с | 100 (101) | 0,39 | 3,9 | 1,23 | 1,01 | 11,4 / 25,2 |
| 400 м, 3 м/с, пара | 190 (101 + 91) | 0,73 | 3,8 | 2,00 | 1,63 | 11,6 / 24,5 |
| 200 м, штиль | 100 (101) | 1,26 | 12,6 | 4,98 | — | 14,9 |
| 200 м, 3 м/с | 130 (131) | 1,59–1,82 | 12–14 | 5,6–6,2 | 4,8–5,1 | 21,4 / 27,5 |
| 200 м, 6 м/с | 150 (151) | 1,88 | 12,6 | 6,13 | 5,11 | 19,8 / 27,6 |
| 200 м, 3 м/с, пара | 280 (151 + 131) | 3,6–3,8 | 12,7–13,7 | 10,4 | 8,4 | 20,9 / 29,2 |
После А1 (θ′_d — второй скаляр тепла, 30.09.2026). Шаг тепла вдвое дороже: два шаблона и 2 × 4 зебры
(итерация 136 → 161 запуск на 400 м, 148 → 173 на 200 м), проверка — ещё один шаблон и редукция.
Парные замеры ревью на свободном GPU (RTX 4070 SUPER, AIR_PICARD_BENCH=1 AIR_PICARD_BENCH_QUICK=1,
до и после подряд; источник — tools/research/a1/review/out/bench_*.log, ветка air/a1-review):
| до | после | прирост | |
|---|---|---|---|
| 400 м, итерация целиком | 3,59 мс (к прежнему числу документа 3,21 — +31 %) | 4,22 мс | +18 % парно |
| 400 м, тепло | 0,98 мс | 1,77 мс | +0,79 мс (весь прирост — второй проход тепла) |
| 400 м, решение 3 м/с с нагревом | 90 итераций, GPU 0,34 с | 130 итераций, GPU 0,58 с | +70 % (итераций +44 %, итерация +18 %) |
| 200 м, итерация целиком | 11,26 мс | 14,20 мс | +26 % |
| 200 м, тепло | 3,14 мс | 6,34 мс | ×2 |
Область 200 м, 3 м/с с нагревом после А1 не сходится (невязка θ′ качается 5,7e-7…1,0e-6 при пороге 5e-7, импульс и ∇·u в норме; статус «max», 3000 итераций, в bench — таймаут 60 с; случай из bench исключён) — задача сходимости А2.
(CuPy-прикидка: 400 м 0,3–0,8 с, 200 м 1,2–4,2 с — это GPU-время.) Ни одна порция не длиннее 30 мс.
Стена ≫ GPU — это не счёт, а кадры. В режиме «кадры» за кадр — одна порция (бюджет — 0,8 × промежуток между кадрами, чтобы sync не ждал); локальный RD нельзя отправить второй раз до sync, а GPU делится с отрисовкой окна: на 200 м главный поток пишет порции 0,18–0,2 с и ждёт в sync 0,02 с за всё решение, остальное — простой между кадрами (~30 мс кадр при ~9 мс порции). В «загрузке» (порции подряд до 40 мс за кадр) ожидание в sync ≈ всё GPU-время (~1,7 с), и всё равно ~3 с стены уходит на кадры: пока идёт наша порция, отрисовка кадра ждёт GPU, и наоборот. Рабочий поток не выход: в Godot 4.7 локальный RenderingDevice — только с потока отрисовки («can only be called from the render thread»). Итог: целевые 1 и 5 с выполнены по GPU (0,25–0,39 и 1,3–1,9 с), по стене в окне тестов — 1,0–1,2 и 4,8–6,2 с (пара — 1,6–2,0 и 8,4–10,4 с). Варианты дальше (не делались): меньше запусков (зебра u, v, w одним запуском — компоненты независимы, −24 запуска; грубые уровни V-цикла одной группой уже есть), без окна игры (библиотека полей — AM-06б — может считать в отдельном процессе без отрисовки).
Цена записи на CPU: ~10 мкс на запуск (run программы + submit); итерация 136–148 запусков →
~1,4 мс CPU на итерацию, за решение 200 м 0,2 с. Подготовка входа места (AirPlace, GDScript) —
0,5–0,6 с; field_async (сборка WindField 400 м в WorkerThreadPool) — 0,6 с до сигнала, кадр
не стоит.
Клипмапы (AM-04): окна вокруг пилота
Уровни: область 400 м (AirPicardJob, 38,4 км) → окно 100 м → окно 50 м, окна 64 × 64 столбца,
Δz = Δx/2 (сетка как real.grid_window эталона: угол кратен 25 м, z_bot = ⌊h_min/dz⌋·dz − dz,
верх — h_max + 2000 м). Родитель окна 100 м — область, окна 50 м — окно 100 м. Контракт — C7.
| Файл | Что |
|---|---|
air_window_case.gd (AirWindowCase) | вход окна (рельеф, погода, солнце — как AirPlace), зона релаксации вместо губок области, без гашения нагрева у края, флаг P_NEST; prepare_pair() — оба случая заранее (рабочий поток) |
air_window.glsl | nest — поле родителя в центрах его клеток (среднее двух граней, 0 в земле) трилинейно во все грани и клетки окна → ubu/ubv/ubw/thb; flux + две редукции + corr — поправка потока Σ = 0 по площади граничных граней; shift — тёплый старт сдвинутого окна |
air_window_job.gd (AirWindowJob) | Пикар окна (наследник AirPicardJob): граница от родителя при подготовке каждого случая; решение без нагрева — от родителя без нагрева; тёплый старт prev |
air_clipmap.gd (AirClipmap) | пирамида: загрузка с центром на старте, сдвиг окон за пилотом, одно устройство на все окна, итог — levels_changed |
air_picard.glsl / air_picard_job.gd | setup при P_NEST не пишет фон ветра на гранях; задача хранит и решение без нагрева целиком (um, vm, thm, pm) — parent_data(), state(mech); чтение буферов после готовности — один раз |
Граница окна от родителя (эталон: air.py → Air.set_nest_bc, reference.md → «Граничные условия области»)
nest: u, v, w, θ′ родителя в центрах его клеток (с ореолом; клетки земли — 0, какAir.centers(full=True)), трилинейно (индексы с отсечением какair.trilinear) в грани u, v, w и клетки окна; грани типа 0 — 0.- Поправка потока: Σ s·u_n·A по граням типа 2 (s — внешняя нормаль, A = Δx·Δz сбоку, Δx² сверху) делится на их площадь и вычитается по нормали — масса через границу окна точно сохраняется.
- Те же массивы — значения на гранях типов 2/3 и в ореоле θ′ (
bcкаждую итерацию) и цель зоны релаксации: губки s = (1 − d/L)²/300 с⁻¹ у всех боковых граней на L = 4 клетки (импульс и θ′) и у потолка на 1000 м тянут u, v, w, θ′ к родителю. Без неё окно 100 м при 6–8 м/с не сходится (эталон). - Старт: всё поле = родитель, 30 V-циклов (p = 0). Решение без нагрева (w_mech) — от решения
родителя без нагрева, с нагревом — от решения с нагревом (как
ref_study.cells).
Сдвиг окна: новый угол = старый + целое число клеток (пилот — у центра); shift берёт неизвестные
из старого окна там, где оно есть (i, j, k сдвигаются на целое: рельеф тех же столбцов тот же), в
новой полосе — родитель; p — старое, у края старого окна — ближайшее; затем 4 V-цикла.
Сверка с эталоном AM-01 (test_air_window_gpu.gd, фикстуры fixtures/air_model/window/, генератор window_gpu_refs.py)
Цепочка как в игре: область 400 м на GPU → окно 100 м → окно 50 м; эталон — air.py float64
той же цепочки. Онгудай, 12:00, ветер с 150°, окна с центром у старта Каянча.
| Окно, ветер | итераций без нагрева / с нагревом (эталон f64, f32) | поле у старта и у края, max|Δ|/|u₀| | θ′, К | граница (грани u запада) | поправка потока, м/с (эталон) | ∇·u СКО | баланс тепла, отн. (эталон) |
|---|---|---|---|---|---|---|---|
| 100 м, 3 м/с | 40 / 40 (41 / 41, 41 / 41) | 3,0e-4 | 6,1e-4 | 1,1e-4 | 1,74e-3 (1,74e-3) | 1,8e-9 | −1,9e-5 (−1,2e-5) |
| 100 м, 6 м/с | 40 / 40 (41 / 41, 41 / 41) | 4,4e-5 | 2,5e-4 | 1,6e-5 | 2,16e-3 (2,16e-3) | 2,9e-9 | 2,6e-5 (2,7e-5) |
| 50 м, 3 м/с (от окна 100 м) | 40 / 50 (41 / 51, 41 / 51) | 1,8e-4 | 6,7e-4 | — | 1,07e-3 | 3,0e-9 | −4,0e-5 (−2,7e-5) |
(Эталон с графом CUDA делает одну разгонную итерацию до счёта — отсюда 41 против 40, как у AM-03.)
Выборка WindField окна над стартом на 10/50/150/400 м — как to_game_field.game_sample до
7e-4 м/с. Два прогона окна (все буферы, итерации) — побитно одинаковы. Вход окна из слоя рельефа
(AirWindowCase.window_case) против real.grid_window: hc точно, H 2,6e-5 Вт/м², dθ̄/dz 2,3e-8 К/м.
Стык уровней (test_clipmap_load_and_shift, картинка tools/research/air_clipmap/out/seam_kayancha_h12_U3.png)
Линия на восток от старта через края окон 50 м (+1,6 км) и 100 м (+3,2 км), выборка игры
(AirFieldSet) и каждый уровень. Разница уровня с родителем у грани окна / в начале полосы края
(5 клеток внутрь) и наибольшее изменение выборки игры за 50 м в полосе края против того же внутри
мелкого окна (% местной скорости):
| Высота | 50 → 100 м: у грани / в 5 клетках | за 50 м: стык / внутри | 100 → 400 м: у грани / в 5 клетках | за 50 м: стык / внутри |
|---|---|---|---|---|
| 50 м над землёй | 10,9 / 18,1 % | 9,4 / 9,0 % | 6,1 / 16,4 % | 11,6 / 17,8 % |
| 300 м над землёй | 0,5 / 1,1 % | 1,3 / 1,1 % | 2,7 / 7,4 % | 4,7 / 2,0 % |
Скачка на стыке нет: изменение поля в полосе края не больше, чем внутри окна (+5 %). Разница
самих уровней у земли (6–18 %) — это разрешение рельефа (400 м занижает разгон у склонов на
5–25 %, эталон), а не граница: у грани окна на 300 м — 0,5–2,7 %. У земли и у грани разница
больше, потому что выборка у земли идёт по рельефу своей сетки (сдвиг по высоте exp(−agl/dx),
лог-профиль ниже первой клетки), а родитель в окно интерполируется по абсолютной высоте с
нулями в клетках земли (как эталон).
Время (RTX 4070 SUPER, общая с другими задачами; окно тестов 320×240; пара без нагрева + с нагревом)
AirClipmap (одно устройство на все окна — ядра собираются один раз). «Загрузка» —
poll_slice(40) (экран загрузки), «полёт» — poll() раз в кадр, сдвиг с тёплого старта:
| Окно | режим | итераций | подготовка входа (рабочий поток), мс | стена GPU-части, мс | GPU, мс | макс. порция, мс | макс. poll главного потока, мс |
|---|---|---|---|---|---|---|---|
| 100 м | загрузка | 40 + 40 | 350–420 | 380–540 | 250–370 | 25 | 44–71 (первое окно: сборка ядер ~200 мс) |
| 50 м | загрузка | 40 + 50 | 470–840 | 580–820 | 420–670 | 25–36 | 54–62 |
| 100 м | полёт, сдвиг | 50 + 40 | 350–570 | 570–780 | 220–340 | 10–18 | 5–15 |
| 50 м | полёт, сдвиг | 30…40 + 60…70 | 410–600 | 800–1380 | 350–670 | 13–20 | 8–19 |
Отдельная задача окна со своим RD (без клипмапа, «кадры»): 100 м 0,8–1,6 с стены при 0,25 с GPU — лишнее — сборка конвейеров ядер при первой записи. Итог: окно 100 м ≤ 1 с и 50 м ≤ 3 с (цели плана) — выполнено и в загрузке, и в полёте (с подготовкой входа — ≤ 1,0 и ≤ 2,0 с); ни одна порция не длиннее 36 мс. Загрузка всех окон после области — 2,5–3,2 с стены (входы окон готовятся параллельно, WindField окна 50 м ~0,5–0,7 с в рабочем потоке). Сдвиг окна 50 м — 1,7–2,3 с от сдвига до нового набора, окон 100 + 50 м — 2,8–3,4 с; наибольший кадр во время сдвига 31–42 мс (цель — ≤ 100 мс). Тёплый старт сдвинутого окна почти не экономит итераций (граница сдвигается вместе с окном): 30–50 + 40–70 против 40 + 40–50 с холодного.
Что убрано из главного потока (было 300 мс кадр при сдвиге): новый RD и сборка ядер на каждое
окно (общее устройство, AirGpu.mark()/free_from() — задача освобождает только свои буферы),
нулевые буферы с CPU (теперь buffer_clear на GPU: заведение ~40 буферов 30–50 → 0,2–11 мс),
повторное чтение одних и тех же буферов (state, parent_data, поле — одно чтение), подготовка
входа окна (гаусс K_b свёрткой со сложенным отражением: σ 15–30 клеток на 64 — 64 отвода вместо
91–181; пара 1,1 → 0,3 с и 3,1 → 0,35 с) — в рабочем потоке.
В игре: AirRuntime (C9 v2)
Game зовёт air_runtime.set_focus(glider, _start_pos): загрузка — область 400 м, затем окна
100/50 м с центром на старте, в атмосферу один раз [окно 50, окно 100, область]; пересчёт по
сроку — область, затем окна на прежних местах с тёплого старта (итераций 20 + 20 и 20 + 40 против
40 + 40 и 40 + 50), подача одним набором с подменой; в покое — сдвиг окон за пилотом. Одно
устройство на игру (RuntimeGpu собирает и ядра окон), задачи по очереди.
test_air_window_gpu.gd::test_runtime_with_windows (Онгудай, 12:00, 3 м/с): загрузка с окнами
5,0–5,5 с стены (кадр ≤ 77 мс, AirRuntime за кадр ≤ 67 мс), сдвиг окна 50 м на 1 км — 2,2–2,5 с
до подачи, кадр ≤ 22–49 мс; пересчёт 12:15 с окнами — 5,0–7,3 с, кадр ≤ 64 мс. Отдельно: первый
шаг атмосферы после подачи поля — сборка источников термиков (AM-07) ~0,4–0,6 с на главном
потоке, от окон не зависит (ключ — грубейший уровень), в замер сдвига не входит.
Итог для координатора (AM-04)
Приёмка:
- Окно 100 м против
air.py(Онгудай, Каянча, 12:00, 3 и 6 м/с): итераций 40/40 против 41/41 (эталон — +1 разгонная), поле ≤ 3,0e-4·|u₀|, θ′ ≤ 6,7e-4 К; окно 50 м — 40/50 против 41/51, 1,8e-4·|u₀| — да. - Стык: в полосе края выборка игры меняется за 50 м не больше, чем внутри окна (+5 %); у грани
окна на 300 м — 0,5–2,7 % — да (у земли уровни различаются на 6–18 % — разрешение рельефа,
сглаживается полосой края; картинка
tools/research/air_clipmap/out/seam_kayancha_h12_U3.png). - Сдвиг окна: наибольший кадр 22–67 мс (клипмап и AirRuntime) — да.
- Время (4070 SUPER, пара): окно 100 м — стена GPU-части 0,38–0,78 с (+ вход в рабочем потоке 0,35–0,57 с), 50 м — 0,58–1,38 с; порции ≤ 36 мс — да.
- Два прогона побитно одинаковы; ∇·u СКО 2–3e-9, баланс тепла как у эталона — да.
- Контрактные тесты зелёные (C7 v1, C9 v2), GPU-тесты модели воздуха 56/56, lint своих файлов
чистый — да; полный headless-прогон не сделан (по решению пользователя — до калибровки);
tools/lint.shпо всему проекту падает на чужих строках > 100 (start_menu, world_key, test_forecast_game) после слияния main. - Документы — этот раздел,
docs/guide/air-model.md→ «Клипмапы», C7 v1, C9 v2 — да.
Не сделано / открыто:
- Скриншот F3 с окнами — снят с задачи (решение К0: оверлеи — отдельная ветка).
- Тёплый старт сдвинутого окна почти не экономит итераций (граница сдвигается вместе с окном).
- Сдвиг — только в покое AirRuntime: если идёт пересчёт области (до 5–7 с с окнами), окна догоняют пилота после него; при ускорении времени ×10 окно 50 м может отставать.
- Замеры — на карте, общей с другими задачами (разброс GPU-времени до ×2); AMD не проверялась.
- Разница уровней у земли (6–18 % на 50 м над землёй) — это граница модели (разрешение), подкручивать не нужно; если пилоты заметят «ступеньку» у края окна — шире полоса края окна.