Модель воздуха на 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.glsl7-точечный шаблон: 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 / mul15 3608,8e-8 / 6,7e-8 / 7,0e-8 / 0 / 0
сумма (x > 0), сумма (±), max|x|, dot15 3603,8e-8, 7,4e-10, 1e-16, 1,1e-9
сумма, dot (n не кратно 256)12 3451,6e-9, 1,4e-10
resid7 / apply723×20×171,1e-7 / 1,1e-7
прогонка x, y, z: зебра и «все линии» (в разделяемой памяти)23×20×172,0e-7 … 3,2e-7
прогонка x, n = 1000 (TPL 256, одна линия на группу)1000×2×11,9e-7
многопроходная прогонка x, n = 2500 / y, n = 13002500×2×1 / 2×1300×11,7e-7 / 1,9e-7
шаблон уровня 0 / 1 из граней32×32×16 / 16×16×161,5e-8 / 7,5e-9
V-цикл ×1 / ×5 (маска с горой, Нейман)32×32×163,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×48192×192×4864×64×112
axpy0,004 (1300–1500*)0,009 (2250*)0,004 (1370*)
dot, 2 прохода0,008 (460*)0,011 (1250*)0,008 (460*)
resid70,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 мс22225,1 мс0,03 мс
192×192×48, бюджет 30 мс352,9 мс4,9 мс (первая порция)
192×192×48, бюджет 2 мс (V-цикл режется)372,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; шаг дороже бюджета режется между кадрами):

  1. Подготовка (в start, без ожидания): загрузка столбцов/уровней/параметров, ядро setup, mgfaces ×4, уровни V-цикла (AirMultigrid.build), K = K_h = K_b.
  2. Старт: bc (фон) + проекция 30 V-циклами без изменения p; тёплый — bc + 4 V-цикла.
  3. 10 итераций + проверка (как Air.solve): итерация — bc, kloc, mom u/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.
  4. finalize: bc + 10 V-циклов без изменения p + ∇·u в скаляры.
  5. При 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.pyhc 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,0060,0161
местное K (kloc)0,0170,111
шаблоны импульса ×30,130,843
прогонки импульса z (2 × u, v, w)0,421,3712
прогонки импульса x0,281,1112
прогонки импульса y0,371,6812
шаблон тепла0,050,291
прогонки тепла z, x, y ×40,832,9124
V-цикл (6 / 7 уровней)1,152,8661 / 73
∇·u, центрирование, поправка0,010,209
итерация целиком3,311,2136 / 148
проверка (раз в 10 итераций)0,401,9819

400 м — данные в L2 (48 МБ), время — запуски и барьеры (V-цикл: 61 запуск на 1,15 мс); 200 м — DRAM, прогонки ~60 %, V-цикл 25 %.

Решения целиком (с нагревом; «пара» — без нагрева + с нагревом, как для игры). GPU — сумма порций по меткам; стена — от start() до готовности в окне тестов: «кадры» — poll() раз в кадр (игра идёт), «загрузка» — poll_slice(40 мс) (экран загрузки):

Сетка, ветеритераций (эталон f32)GPU, смс/итер.стена, кадры, сстена, загрузка, смакс. порция, мс
400 м, штиль60 (61)0,254,20,97—11,6
400 м, 3 м/с90 (91)0,353,91,120,9611,7 / 24,0
400 м, 6 м/с100 (101)0,393,91,231,0111,4 / 25,2
400 м, 3 м/с, пара190 (101 + 91)0,733,82,001,6311,6 / 24,5
200 м, штиль100 (101)1,2612,64,98—14,9
200 м, 3 м/с130 (131)1,59–1,8212–145,6–6,24,8–5,121,4 / 27,5
200 м, 6 м/с150 (151)1,8812,66,135,1119,8 / 27,6
200 м, 3 м/с, пара280 (151 + 131)3,6–3,812,7–13,710,48,420,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.glslnest — поле родителя в центрах его клеток (среднее двух граней, 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.gdsetup при P_NEST не пишет фон ветра на гранях; задача хранит и решение без нагрева целиком (um, vm, thm, pm) — parent_data(), state(mech); чтение буферов после готовности — один раз

Граница окна от родителя (эталон: air.py → Air.set_nest_bc, reference.md → «Граничные условия области»)

  1. nest: u, v, w, θ′ родителя в центрах его клеток (с ореолом; клетки земли — 0, как Air.centers(full=True)), трилинейно (индексы с отсечением как air.trilinear) в грани u, v, w и клетки окна; грани типа 0 — 0.
  2. Поправка потока: Σ s·u_n·A по граням типа 2 (s — внешняя нормаль, A = Δx·Δz сбоку, Δx² сверху) делится на их площадь и вычитается по нормали — масса через границу окна точно сохраняется.
  3. Те же массивы — значения на гранях типов 2/3 и в ореоле θ′ (bc каждую итерацию) и цель зоны релаксации: губки s = (1 − d/L)²/300 с⁻¹ у всех боковых граней на L = 4 клетки (импульс и θ′) и у потолка на 1000 м тянут u, v, w, θ′ к родителю. Без неё окно 100 м при 6–8 м/с не сходится (эталон).
  4. Старт: всё поле = родитель, 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-46,1e-41,1e-41,74e-3 (1,74e-3)1,8e-9−1,9e-5 (−1,2e-5)
100 м, 6 м/с40 / 40 (41 / 41, 41 / 41)4,4e-52,5e-41,6e-52,16e-3 (2,16e-3)2,9e-92,6e-5 (2,7e-5)
50 м, 3 м/с (от окна 100 м)40 / 50 (41 / 51, 41 / 51)1,8e-46,7e-4—1,07e-33,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 + 40350–420380–540250–3702544–71 (первое окно: сборка ядер ~200 мс)
50 мзагрузка40 + 50470–840580–820420–67025–3654–62
100 мполёт, сдвиг50 + 40350–570570–780220–34010–185–15
50 мполёт, сдвиг30…40 + 60…70410–600800–1380350–67013–208–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)

Приёмка:

  1. Окно 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₀| — да.
  2. Стык: в полосе края выборка игры меняется за 50 м не больше, чем внутри окна (+5 %); у грани окна на 300 м — 0,5–2,7 % — да (у земли уровни различаются на 6–18 % — разрешение рельефа, сглаживается полосой края; картинка tools/research/air_clipmap/out/seam_kayancha_h12_U3.png).
  3. Сдвиг окна: наибольший кадр 22–67 мс (клипмап и AirRuntime) — да.
  4. Время (4070 SUPER, пара): окно 100 м — стена GPU-части 0,38–0,78 с (+ вход в рабочем потоке 0,35–0,57 с), 50 м — 0,58–1,38 с; порции ≤ 36 мс — да.
  5. Два прогона побитно одинаковы; ∇·u СКО 2–3e-9, баланс тепла как у эталона — да.
  6. Контрактные тесты зелёные (C7 v1, C9 v2), GPU-тесты модели воздуха 56/56, lint своих файлов чистый — да; полный headless-прогон не сделан (по решению пользователя — до калибровки); tools/lint.sh по всему проекту падает на чужих строках > 100 (start_menu, world_key, test_forecast_game) после слияния main.
  7. Документы — этот раздел, docs/guide/air-model.md → «Клипмапы», C7 v1, C9 v2 — да.

Не сделано / открыто:

  • Скриншот F3 с окнами — снят с задачи (решение К0: оверлеи — отдельная ветка).
  • Тёплый старт сдвинутого окна почти не экономит итераций (граница сдвигается вместе с окном).
  • Сдвиг — только в покое AirRuntime: если идёт пересчёт области (до 5–7 с с окнами), окна догоняют пилота после него; при ускорении времени ×10 окно 50 м может отставать.
  • Замеры — на карте, общей с другими задачами (разброс GPU-времени до ×2); AMD не проверялась.
  • Разница уровней у земли (6–18 % на 50 м над землёй) — это граница модели (разрешение), подкручивать не нужно; если пилоты заметят «ступеньку» у края окна — шире полоса края окна.