Files
CFDManager/docs/theory/2d_solver/src/gpu.rs
T
NotBigGhostandClaude Opus 5 d5e37fcb8d Раздельные привязки популяций: снят предел dzn на размер сетки
Массив популяций занимает nx*ny*9*4 байта и показывался шейдеру одной привязкой.
dzn объявляет max_storage_buffer_binding_size = 128 МиБ, поэтому потолок выходил
3.73 млн узлов: 14 прогонов кампании из 115 падали на создании bind group, а на
них приходится 70.7% её стоимости.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:08:12 +03:00

2039 lines
88 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! БЭКЕНД ПОД ВИДЕОКАРТУ на wgpu (Vulkan / DX12 / Metal).
//!
//! Физика построчно повторяет `math.rs`, но на f32: в WGSL нет двойной точности. Это законно
//! именно потому, что порог вырожденности γ в схеме ОТНОСИТЕЛЬНЫЙ (`GREL`, доля от ⟨Δ|Δ⟩), а не
//! абсолютный — иначе в f32 он срабатывал бы на подавляющем большинстве узлов и схема молча
//! выродилась бы в LBGK. Топология задачи (маски, Bouzidi-линки, рамка и рестрикция патча) не
//! дублируется: она строится теми же процедурами из `cpu`, а сюда загружается готовыми списками.
//!
//! Раскладка полей — SoA: `f[i*n + node]`. В отличие от AoS на процессоре, здесь важен
//! коалесцированный доступ: соседние потоки читают соседние узлы одной и той же популяции.
//!
//! Ограничение реализации: за каждый шаг делается одно чтение 48-байтового буфера итогов
//! (сила, зонд, статистика). Это синхронизация с GPU на каждом шаге; она заметно ограничивает
//! частоту шагов на мелких сетках, зато `step()` возвращает те же величины, что и процессорный
//! бэкенд, шаг в шаг. На крупных сетках стоимость самой синхронизации теряется на фоне счёта.
use std::borrow::Cow;
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use crate::cpu;
use crate::math::{self, R};
use crate::{Case, Collision, FieldKind, Spec, StepRec};
const WG: u32 = 64;
/// Сколько шагов копится в буфере итогов до одной синхронизации с устройством.
///
/// Раньше после каждого шага делалось `map_async` + `poll(Wait)` ради 48 байт: на сетке
/// 240×120 счёт упирался в 863 шаг/с при том, что сам счёт занимал 0.27 мс из 1.16 — три
/// четверти времени машина стояла. Теперь `k_stats2` пишет итоги в слот `step % HIST`, а
/// хост читает всю пачку разом. Обязано совпадать с `HIST` в шейдере.
const HIST: usize = 128;
// ─────────────────────────────────────────────────────────────────────────────
// Структуры, разделяемые с шейдером
// ─────────────────────────────────────────────────────────────────────────────
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct LevelParams {
n: u32,
nx: u32,
ny: u32,
nlinks: u32,
bcx: f32,
bcy: f32,
probe_node: u32,
/// бит 0 — писать частичные суммы статистики; бит 1 — на этом уровне лежит зонд
flags: u32,
/// число граничных узлов (для моментных моделей стенки)
nwall: u32,
/// шаг между направлениями в буфере популяций, во флоатах (см. dir_stride)
stride: u32,
_pad: [u32; 2],
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct Dyn {
ux_in: f32,
uy_in: f32,
rho_out: f32,
/// 0 — сдвиговая часть только девиатор (N1), 1 — девиатор со следом (N2)
kbc_model: u32,
outlet_extrap: u32,
nparts: u32,
refine: u32,
collision: u32,
/// Слот истории, в который `k_stats2` кладёт итоги этого шага.
slot: u32,
/// Режим стенки: 0 — Bouzidi, 1 — Град, 2 — HRR, 3 — простой отскок.
wall_mode: u32,
_pad: [u32; 2],
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct Substep {
idx: u32,
_p: [u32; 3],
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct AmrParams {
nghost: u32,
nrestrict: u32,
/// шаги между направлениями в буферах популяций грубого и мелкого уровней
cstride: u32,
fstride: u32,
r01: f32,
rfc: f32,
w: f32,
_pad: f32,
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct GLink {
node: u32,
far: u32,
i: u32,
ib: u32,
kind: u32,
q: f32,
body: u32,
_p: u32,
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct GGhost {
fine: u32,
c00: u32,
c10: u32,
c01: u32,
c11: u32,
tx: f32,
ty: f32,
_p: u32,
}
/// Итоги шага, читаемые на хост. Порядок обязан совпадать с `results` в шейдере.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Default, Debug)]
struct Results {
fx: f32,
fy: f32,
tz: f32,
uy_probe: f32,
rho_sum: f32,
max_u: f32,
g_sum: f32,
g_min: f32,
g_max: f32,
cnt: f32,
degen: f32,
xineg: f32,
/// Сила и момент по телам: [b*3 + c].
body: [f32; 12],
}
// ─────────────────────────────────────────────────────────────────────────────
// Шейдер
// ─────────────────────────────────────────────────────────────────────────────
const SHADER: &str = r#"
// ───── структуры (обязаны совпадать с gpu.rs) ─────
struct LevelParams { n:u32, nx:u32, ny:u32, nlinks:u32, bcx:f32, bcy:f32, probe_node:u32, flags:u32, nwall:u32, stride:u32, lp2:u32, lp3:u32 };
// wall_mode: 0 — Bouzidi, 1 — Град, 2 — HRR, 3 — простой отскок
struct Dyn { ux_in:f32, uy_in:f32, rho_out:f32, kbc_model:u32, outlet_extrap:u32, nparts:u32, refine:u32, collision:u32, slot:u32, wall_mode:u32, dp2:u32, dp3:u32 };
struct Substep { idx:u32, p0:u32, p1:u32, p2:u32 };
struct AmrParams { nghost:u32, nrestrict:u32, cstride:u32, fstride:u32, r01:f32, rfc:f32, w:f32, pad:f32 };
struct GLink { node:u32, far:u32, i:u32, ib:u32, kind:u32, q:f32, body:u32, p1:u32 };
struct GGhost { fine:u32, c00:u32, c10:u32, c01:u32, c11:u32, tx:f32, ty:f32, p0:u32 };
struct Partial { rho:f32, maxu:f32, gsum:f32, gmin:f32, gmax:f32, cnt:f32, degen:f32, xineg:f32 };
@group(0) @binding(2) var<storage, read_write> gam : array<f32>;
@group(0) @binding(3) var<storage, read> solid: array<u32>;
@group(0) @binding(4) var<storage, read> links: array<GLink>;
@group(0) @binding(5) var<storage, read> beta : array<f32>;
@group(0) @binding(6) var<storage, read_write> parts: array<Partial>;
@group(0) @binding(7) var<uniform> P : LevelParams;
//__POPULATIONS__
@group(1) @binding(0) var<uniform> D : Dyn;
@group(1) @binding(1) var<uniform> S : Substep;
// results[0..11] — итоги шага, results[16 + s*4 + c] — сила подшага s.
// Сила и итоги держатся в ОДНОМ буфере намеренно: связка уровня использует 7 storage-биндингов,
// и отдельный буфер сил вывел бы конвейер за предел 8, гарантируемый лимитами по умолчанию.
@group(1) @binding(2) var<storage, read_write> results : array<f32>;
@group(2) @binding(0) var<storage, read> amr_src: array<f32>;
@group(2) @binding(1) var<storage, read_write> amr_dst: array<f32>;
@group(2) @binding(2) var<storage, read> ghosts : array<GGhost>;
@group(2) @binding(3) var<storage, read> gh_a : array<f32>;
@group(2) @binding(4) var<storage, read> gh_b : array<f32>;
@group(2) @binding(5) var<storage, read> rest : array<vec2<u32>>;
@group(2) @binding(6) var<uniform> A : AmrParams;
// (узел, смещение первого линка, число линков, —). Линки одного узла лежат подряд.
@group(3) @binding(0) var<storage, read> wnodes : array<vec4<u32>>;
const GREL: f32 = 1e-8;
const CS2 : f32 = 0.3333333333;
// Раскладка буфера итогов. results[slot*12 .. +12) — история одного шага; за историей идут
// рабочие слоты силы по подшагам. История нужна, чтобы не синхронизироваться с устройством
// на каждом шаге: результаты копятся и читаются пачкой (см. HIST в gpu.rs).
const HIST: u32 = 128u;
const SLOT: u32 = 24u; // чисел на шаг: 12 общих + 4 тела по 3
const MAXB: u32 = 4u; // вёдер силы по телам
const FORCE_BASE: u32 = 3072u; // = HIST*SLOT
// Компенсированное сложение Кэхена–Ноймайера: возвращает (сумма, накопленная поправка).
// Наивная сумма по 10^5…10^7 значений в f32 съедает ~log2(N) бит; здесь потеря не копится,
// а итог берётся как s + c. Стоит несколько операций на элемент.
fn kadd(s: f32, c: f32, x: f32) -> vec2<f32> {
let t = s + x;
var cc: f32;
if (abs(s) >= abs(x)) { cc = c + ((s - t) + x); } else { cc = c + ((x - t) + s); }
return vec2<f32>(t, cc);
}
// ───── физика: построчный перенос math.rs ─────
// Энтропийное равновесие в product-form (точный максимизатор энтропии при заданных rho, rho*u)
fn feq9(rho: f32, ux0: f32, uy0: f32) -> array<f32,9> {
let ux = clamp(ux0, -0.95, 0.95);
let uy = clamp(uy0, -0.95, 0.95);
let sx = sqrt(1.0 + 3.0*ux*ux);
let sy = sqrt(1.0 + 3.0*uy*uy);
let base = rho * (2.0 - sx) * (2.0 - sy);
let qx = (2.0*ux + sx) / (1.0 - ux);
let qy = (2.0*uy + sy) / (1.0 - uy);
let ix = 1.0 / qx;
let iy = 1.0 / qy;
return array<f32,9>(
base*(4.0/9.0),
base*(1.0/9.0)*qx, base*(1.0/9.0)*qy, base*(1.0/9.0)*ix, base*(1.0/9.0)*iy,
base*(1.0/36.0)*qx*qy, base*(1.0/36.0)*ix*qy, base*(1.0/36.0)*ix*iy, base*(1.0/36.0)*qx*iy);
}
fn macros9(fv: array<f32,9>) -> vec3<f32> {
let rho = fv[0]+fv[1]+fv[2]+fv[3]+fv[4]+fv[5]+fv[6]+fv[7]+fv[8];
let mx = fv[1]+fv[5]+fv[8]-fv[3]-fv[6]-fv[7];
let my = fv[2]+fv[5]+fv[6]-fv[4]-fv[7]-fv[8];
return vec3<f32>(rho, mx/rho, my/rho);
}
// Проекция неравновесия на сдвиговую часть. model=0: {N, Pi_xy} (N1); model=1: плюс след T (N2)
fn shift9(d: array<f32,9>, model: u32) -> array<f32,9> {
let a = 0.25*(d[1] + d[3] - d[2] - d[4]);
let b = 0.25*(d[5] + d[7] - d[6] - d[8]);
var s = array<f32,9>(0.0, a, -a, a, -a, b, -b, b, -b);
if (model == 1u) {
let dt = d[1] + d[2] + d[3] + d[4] + 2.0*(d[5] + d[6] + d[7] + d[8]);
let q = 0.25*dt;
s[0] = s[0] - dt;
s[1] = s[1] + q; s[2] = s[2] + q; s[3] = s[3] + q; s[4] = s[4] + q;
}
return s;
}
fn load9(base: ptr<function, array<f32,9>>, off: u32) {
for (var i = 0u; i < 9u; i = i + 1u) { (*base)[i] = fget(i, off); }
}
// Возвращает (пост-столкновительные популяции, gamma, признак вырождения)
struct CollOut { fv: array<f32,9>, gamma: f32, degen: f32 };
fn collide9(fin: array<f32,9>, b: f32, op: u32, model: u32) -> CollOut {
var out: CollOut;
// WGSL разрешает переменный индекс только по памяти (var), а не по значению (let/параметр),
// поэтому всё, что индексируется в цикле, кладётся в var
var fv = fin;
let m = macros9(fin);
var fe = feq9(m.x, m.y, m.z);
if (op == 1u) { // LBGK: то же самое при gamma = 2
var g: array<f32,9>;
for (var i = 0u; i < 9u; i = i + 1u) { g[i] = fv[i] + 2.0*b*(fe[i] - fv[i]); }
out.fv = g; out.gamma = 2.0; out.degen = 0.0;
return out;
}
var d: array<f32,9>;
for (var i = 0u; i < 9u; i = i + 1u) { d[i] = fv[i] - fe[i]; }
var ds = shift9(d, model);
var num = 0.0; var den = 0.0; var nrm = 0.0;
for (var i = 0u; i < 9u; i = i + 1u) {
let inv = 1.0 / fe[i];
let dh = d[i] - ds[i];
num = num + ds[i]*dh*inv;
den = den + dh*dh*inv;
nrm = nrm + d[i]*d[i]*inv;
}
// относительный порог: den квадратична по неравновесию и физически мала
let ok = den > GREL*nrm;
let binv = 1.0 / b;
var gm = 2.0;
if (ok) { gm = binv - (2.0 - binv)*num/den; }
var g: array<f32,9>;
for (var i = 0u; i < 9u; i = i + 1u) {
let dh = d[i] - ds[i];
g[i] = fv[i] - b*(2.0*ds[i] + gm*dh);
}
out.fv = g; out.gamma = gm;
out.degen = select(1.0, 0.0, ok);
return out;
}
fn zou_he_inlet(fin: array<f32,9>, ux: f32, uy: f32) -> array<f32,9> {
var g = fin;
let rho = (g[0] + g[2] + g[4] + 2.0*(g[3] + g[6] + g[7])) / (1.0 - ux);
let dd = 0.5*(g[2] - g[4]);
g[1] = g[3] + (2.0/3.0)*rho*ux;
g[5] = g[7] - dd + (1.0/6.0)*rho*ux + 0.5*rho*uy;
g[8] = g[6] + dd + (1.0/6.0)*rho*ux - 0.5*rho*uy;
return g;
}
fn zou_he_outlet(fin: array<f32,9>, rho_out: f32, uy_out: f32) -> array<f32,9> {
var g = fin;
let ux = (g[0] + g[2] + g[4] + 2.0*(g[1] + g[5] + g[8])) / rho_out - 1.0;
let dd = 0.5*(g[2] - g[4]);
g[3] = g[1] - (2.0/3.0)*rho_out*ux;
g[6] = g[8] - dd - (1.0/6.0)*rho_out*ux + 0.5*rho_out*uy_out;
g[7] = g[5] + dd - (1.0/6.0)*rho_out*ux - 0.5*rho_out*uy_out;
return g;
}
// ───── ядра ─────
// У GPU жёсткий предел на число рабочих групп в ОДНОМ измерении (65535). Сетка 4096×2048 —
// это 131072 группы по 64 узла, то есть вдвое больше предела, и запуск падает с ошибкой
// валидации, а не считает медленно. Поэтому диспетчеризация двумерная, а линейный индекс
// собирается обратно вручную. Сборка точная: gid.x = wid.x*64 + lid.x, поэтому
// lin() = (wid.y*nwg.x + wid.x)*64 + lid.x — то есть номер группы тоже остаётся линейным.
fn lin(gid: vec3<u32>, nwg: vec3<u32>) -> u32 { return gid.y * nwg.x * 64u + gid.x; }
fn wlin(wid: vec3<u32>, nwg: vec3<u32>) -> u32 { return wid.y * nwg.x + wid.x; }
@compute @workgroup_size(64)
fn k_collide(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(num_workgroups) nwg: vec3<u32>) {
let nd = lin(gid, nwg);
if (nd >= P.n) { return; }
var fv: array<f32,9>;
load9(&fv, nd);
if (solid[nd] != 0u) {
// внутри тела не считаем: популяции там фиктивны, Bouzidi всё равно их перекрывает
for (var i = 0u; i < 9u; i = i + 1u) { pset(i, nd, fv[i]); }
gam[nd] = 2.0;
return;
}
var r = collide9(fv, beta[nd % P.nx], D.collision, D.kbc_model);
for (var i = 0u; i < 9u; i = i + 1u) { pset(i, nd, r.fv[i]); }
gam[nd] = r.gamma;
}
@compute @workgroup_size(64)
fn k_stream(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(num_workgroups) nwg: vec3<u32>) {
let nd = lin(gid, nwg);
if (nd >= P.n) { return; }
var cx = array<i32,9>(0, 1, 0, -1, 0, 1, -1, -1, 1);
var cy = array<i32,9>(0, 0, 1, 0, -1, 1, 1, -1, -1);
let x = i32(nd % P.nx);
let y = i32(nd / P.nx);
let nxi = i32(P.nx);
let nyi = i32(P.ny);
for (var i = 0u; i < 9u; i = i + 1u) {
var xs = (x - cx[i]) % nxi; if (xs < 0) { xs = xs + nxi; }
var ys = (y - cy[i]) % nyi; if (ys < 0) { ys = ys + nyi; }
fset(i, nd, pget(i, u32(ys*nxi + xs)));
}
}
@compute @workgroup_size(64)
fn k_bouzidi(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(num_workgroups) nwg: vec3<u32>) {
let k = lin(gid, nwg);
if (k >= P.nlinks) { return; }
let L = links[k];
let fi = pget(L.i, L.node);
var v = fi;
// ступенчатая модель игнорирует долю пересечения: стенка ровно посередине между узлами
if (D.wall_mode == 3u) {
fset(L.ib, L.node, fi);
return;
}
if (L.kind == 0u) { // q < 1/2, есть дальний жидкий сосед
v = 2.0*L.q*fi + (1.0 - 2.0*L.q)*pget(L.i, L.far);
} else if (L.kind == 1u) { // q >= 1/2
let h = 1.0/(2.0*L.q);
v = h*fi + (1.0 - h)*pget(L.ib, L.node);
}
fset(L.ib, L.node, v);
}
@compute @workgroup_size(64)
fn k_walls(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(num_workgroups) nwg: vec3<u32>) {
let x = lin(gid, nwg);
if (x >= P.nx) { return; }
// зеркальное отражение: касательный импульс сохраняется, нормальный заворачивается
fset(2u, x, fget(4u, x));
fset(5u, x, fget(8u, x));
fset(6u, x, fget(7u, x));
let t = (P.ny - 1u)*P.nx + x;
fset(4u, t, fget(2u, t));
fset(7u, t, fget(6u, t));
fset(8u, t, fget(5u, t));
}
@compute @workgroup_size(64)
fn k_channel(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(num_workgroups) nwg: vec3<u32>) {
let y = lin(gid, nwg);
if (y >= P.ny) { return; }
// вход: скоростной Zou-He по ВСЕМУ столбцу, включая угловые узлы
let a = y*P.nx;
var fv: array<f32,9>;
load9(&fv, a);
var gi = zou_he_inlet(fv, D.ux_in, D.uy_in);
for (var i = 0u; i < 9u; i = i + 1u) { fset(i, a, gi[i]); }
// выход: давление-Zou-He
let b = y*P.nx + P.nx - 1u;
var gv: array<f32,9>;
load9(&gv, b);
var uyo = 0.0;
if (D.outlet_extrap != 0u) {
let c = y*P.nx + P.nx - 2u;
var cv: array<f32,9>;
load9(&cv, c);
let s = cv[0]+cv[1]+cv[2]+cv[3]+cv[4]+cv[5]+cv[6]+cv[7]+cv[8];
uyo = ((cv[2]+cv[5]+cv[6]) - (cv[4]+cv[7]+cv[8])) / s;
}
var go = zou_he_outlet(gv, D.rho_out, uyo);
for (var i = 0u; i < 9u; i = i + 1u) { fset(i, b, go[i]); }
}
// сила и момент по GMEM, редукция одной рабочей группой
var<workgroup> wfx: array<f32, 256>;
var<workgroup> wfy: array<f32, 256>;
var<workgroup> wtz: array<f32, 256>;
@compute @workgroup_size(256)
fn k_force(@builtin(local_invocation_id) lid: vec3<u32>) {
let t = lid.x;
var cx = array<f32,9>(0.0, 1.0, 0.0, -1.0, 0.0, 1.0, -1.0, -1.0, 1.0);
var cy = array<f32,9>(0.0, 0.0, 1.0, 0.0, -1.0, 1.0, 1.0, -1.0, -1.0);
// накопители на каждое тело держатся в регистрах, редукция потом идёт по телам подряд
var pfx = array<f32,4>(0.0, 0.0, 0.0, 0.0);
var pfy = array<f32,4>(0.0, 0.0, 0.0, 0.0);
var ptz = array<f32,4>(0.0, 0.0, 0.0, 0.0);
var k = t;
loop {
if (k >= P.nlinks) { break; }
let L = links[k];
let fp = pget(L.i, L.node);
let fb = fget(L.ib, L.node);
let dfx = cx[L.i]*fp + cx[L.i]*fb;
let dfy = cy[L.i]*fp + cy[L.i]*fb;
// плечо до точки пересечения линка со стенкой, а не до узла
let rx = f32(L.node % P.nx) - P.bcx + L.q*cx[L.i];
let ry = f32(L.node / P.nx) - P.bcy + L.q*cy[L.i];
let b = min(L.body, MAXB - 1u);
pfx[b] = pfx[b] + dfx;
pfy[b] = pfy[b] + dfy;
ptz[b] = ptz[b] + rx*dfy - ry*dfx;
k = k + 256u;
}
for (var b = 0u; b < MAXB; b = b + 1u) {
wfx[t] = pfx[b]; wfy[t] = pfy[b]; wtz[t] = ptz[b];
workgroupBarrier();
var s = 128u;
loop {
if (s == 0u) { break; }
if (t < s) {
wfx[t] = wfx[t] + wfx[t + s];
wfy[t] = wfy[t] + wfy[t + s];
wtz[t] = wtz[t] + wtz[t + s];
}
workgroupBarrier();
s = s >> 1u;
}
if (t == 0u) {
let o = FORCE_BASE + (S.idx*MAXB + b)*4u;
results[o + 0u] = wfx[0];
results[o + 1u] = wfy[0];
results[o + 2u] = wtz[0];
}
workgroupBarrier();
}
}
// частичные суммы диагностики: одна Partial на рабочую группу
var<workgroup> wp: array<Partial, 64>;
@compute @workgroup_size(64)
fn k_stats1(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_id) lid: vec3<u32>,
@builtin(workgroup_id) wid: vec3<u32>,
@builtin(num_workgroups) nwg: vec3<u32>) {
let nd = lin(gid, nwg);
let t = lid.x;
var p: Partial;
p.rho = 0.0; p.maxu = 0.0; p.gsum = 0.0; p.gmin = 1e30; p.gmax = -1e30;
p.cnt = 0.0; p.degen = 0.0; p.xineg = 0.0;
if (nd < P.n && solid[nd] == 0u) {
var fv: array<f32,9>;
load9(&fv, nd);
let m = macros9(fv);
p.rho = m.x;
p.maxu = sqrt(m.y*m.y + m.z*m.z);
let g = gam[nd];
p.gsum = g; p.gmin = g; p.gmax = g; p.cnt = 1.0;
// вырожденный узел помечен ровно gamma = 2 (см. k_collide)
p.degen = select(0.0, 1.0, g == 2.0);
// объёмная вязкость модели D: xi = cs^2 (1/(gamma*beta) - 1/2)
let b = beta[nd % P.nx];
// при следе в сдвиговой части (N2) объёмная вязкость равна сдвиговой и всегда > 0
var xi = CS2*(1.0/(b + b) - 0.5);
if (D.kbc_model == 0u) { xi = CS2*(1.0/(g*b) - 0.5); }
p.xineg = select(0.0, 1.0, xi < 0.0);
}
// зонд следа снимается с того уровня, на котором он лежит
if ((P.flags & 2u) != 0u && nd == P.probe_node) {
var fv: array<f32,9>;
load9(&fv, nd);
let m = macros9(fv);
results[D.slot*SLOT + 3u] = m.z;
}
wp[t] = p;
workgroupBarrier();
var s = 32u;
loop {
if (s == 0u) { break; }
if (t < s) {
let o = wp[t + s];
wp[t].rho = wp[t].rho + o.rho;
wp[t].maxu = max(wp[t].maxu, o.maxu);
wp[t].gsum = wp[t].gsum + o.gsum;
wp[t].gmin = min(wp[t].gmin, o.gmin);
wp[t].gmax = max(wp[t].gmax, o.gmax);
wp[t].cnt = wp[t].cnt + o.cnt;
wp[t].degen = wp[t].degen + o.degen;
wp[t].xineg = wp[t].xineg + o.xineg;
}
workgroupBarrier();
s = s >> 1u;
}
// хвостовые группы двумерной сетки лежат за пределами поля и в parts не пишут:
// отображение группа→узлы осталось линейным, поэтому wl >= nparts ⇒ узлы >= n
let wl = wlin(wid, nwg);
if (t == 0u && wl < D.nparts && (P.flags & 1u) != 0u) { parts[wl] = wp[0]; }
}
// Размер группы обязан совпадать с длиной wp: при 256 потоках на 64 ячейки четыре потока
// писали бы в одну и ту же ячейку и три четверти частичных сумм терялись бы молча.
@compute @workgroup_size(64)
fn k_stats2(@builtin(local_invocation_id) lid: vec3<u32>) {
let t = lid.x;
// слагаемых до 10^5 на поток — здесь и живёт основная потеря f32, поэтому все
// аддитивные накопители компенсированные
var rho = vec2<f32>(0.0, 0.0);
var gsum = vec2<f32>(0.0, 0.0);
var cnt = vec2<f32>(0.0, 0.0);
var degen = vec2<f32>(0.0, 0.0);
var xineg = vec2<f32>(0.0, 0.0);
var maxu = 0.0; var gmin = 1e30; var gmax = -1e30;
var k = t;
loop {
if (k >= D.nparts) { break; }
let o = parts[k];
rho = kadd(rho.x, rho.y, o.rho);
gsum = kadd(gsum.x, gsum.y, o.gsum);
cnt = kadd(cnt.x, cnt.y, o.cnt);
degen = kadd(degen.x, degen.y, o.degen);
xineg = kadd(xineg.x, xineg.y, o.xineg);
maxu = max(maxu, o.maxu);
gmin = min(gmin, o.gmin);
gmax = max(gmax, o.gmax);
k = k + 64u;
}
var p: Partial;
p.rho = rho.x + rho.y; p.gsum = gsum.x + gsum.y; p.cnt = cnt.x + cnt.y;
p.degen = degen.x + degen.y; p.xineg = xineg.x + xineg.y;
p.maxu = maxu; p.gmin = gmin; p.gmax = gmax;
wp[t] = p;
workgroupBarrier();
// сведение 256 потоков через 64 ячейки делаем последовательно нулевым потоком:
// объём данных крошечный, а корректность важнее пары микросекунд
if (t == 0u) {
var qrho = vec2<f32>(0.0, 0.0);
var qgsum = vec2<f32>(0.0, 0.0);
var qcnt = vec2<f32>(0.0, 0.0);
var qdeg = vec2<f32>(0.0, 0.0);
var qxin = vec2<f32>(0.0, 0.0);
var qmaxu = 0.0; var qgmin = 1e30; var qgmax = -1e30;
for (var j = 0u; j < 64u; j = j + 1u) {
let o = wp[j];
qrho = kadd(qrho.x, qrho.y, o.rho);
qgsum = kadd(qgsum.x, qgsum.y, o.gsum);
qcnt = kadd(qcnt.x, qcnt.y, o.cnt);
qdeg = kadd(qdeg.x, qdeg.y, o.degen);
qxin = kadd(qxin.x, qxin.y, o.xineg);
qmaxu = max(qmaxu, o.maxu);
qgmin = min(qgmin, o.gmin);
qgmax = max(qgmax, o.gmax);
}
// сила усредняется по подшагам L1 и раскладывается по телам; общая — их сумма
let inv = 1.0 / f32(D.refine);
let o = D.slot*SLOT;
var tfx = 0.0; var tfy = 0.0; var ttz = 0.0;
for (var b = 0u; b < MAXB; b = b + 1u) {
var fx = 0.0; var fy = 0.0; var tz = 0.0;
for (var sb = 0u; sb < D.refine; sb = sb + 1u) {
let g = FORCE_BASE + (sb*MAXB + b)*4u;
fx = fx + results[g + 0u];
fy = fy + results[g + 1u];
tz = tz + results[g + 2u];
}
results[o + 12u + b*3u + 0u] = fx*inv;
results[o + 12u + b*3u + 1u] = fy*inv;
results[o + 12u + b*3u + 2u] = tz*inv;
tfx = tfx + fx*inv; tfy = tfy + fy*inv; ttz = ttz + tz*inv;
}
results[o + 0u] = tfx;
results[o + 1u] = tfy;
results[o + 2u] = ttz;
results[o + 4u] = qrho.x + qrho.y;
results[o + 5u] = qmaxu;
results[o + 6u] = qgsum.x + qgsum.y;
results[o + 7u] = qgmin;
results[o + 8u] = qgmax;
results[o + 9u] = qcnt.x + qcnt.y;
results[o + 10u] = qdeg.x + qdeg.y;
results[o + 11u] = qxin.x + qxin.y;
}
}
// ───── граничное условие на моментах (Град / HRR) ─────
// Скорость узла на момент t: берётся из ПОСТ-СТОЛКНОВИТЕЛЬНОГО поля. Столкновение сохраняет
// rho и rho*u точно, поэтому это ровно u(x, t) — то, что требует прил. B, без отдельного
// хранилища прошлого шага.
fn u_at_t(nd: u32) -> vec2<f32> {
var ff: array<f32,9>;
for (var i = 0u; i < 9u; i = i + 1u) { ff[i] = pget(i, nd); }
let m = macros9(ff);
return vec2<f32>(m.y, m.z);
}
// (du/dx, du/dy, dv/dx, dv/dy): центральная разность там, где оба соседа жидкие,
// односторонняя — где один твёрдый, ноль — если твёрдые оба.
fn grad_u_at_t(nd: u32) -> vec4<f32> {
let x = nd % P.nx;
let y = nd / P.nx;
let xm = y*P.nx + (x + P.nx - 1u) % P.nx;
let xp = y*P.nx + (x + 1u) % P.nx;
let ym = ((y + P.ny - 1u) % P.ny)*P.nx + x;
let yp = ((y + 1u) % P.ny)*P.nx + x;
let uc = u_at_t(nd);
var dx = vec2<f32>(0.0, 0.0);
let am = solid[xm] == 0u; let ap = solid[xp] == 0u;
if (am && ap) { dx = 0.5*(u_at_t(xp) - u_at_t(xm)); }
else if (ap) { dx = u_at_t(xp) - uc; }
else if (am) { dx = uc - u_at_t(xm); }
var dy = vec2<f32>(0.0, 0.0);
let bm = solid[ym] == 0u; let bp = solid[yp] == 0u;
if (bm && bp) { dy = 0.5*(u_at_t(yp) - u_at_t(ym)); }
else if (bp) { dy = u_at_t(yp) - uc; }
else if (bm) { dy = uc - u_at_t(ym); }
return vec4<f32>(dx.x, dy.x, dx.y, dy.y);
}
// Приближение Града, ур. (2.13): популяции по rho, u и полному тензору давлений.
fn grad_init9(rho: f32, ux: f32, uy: f32, pxx: f32, pxy: f32, pyy: f32) -> array<f32,9> {
let axx = pxx - rho*CS2;
let ayy = pyy - rho*CS2;
var w = array<f32,9>(4.0/9.0, 1.0/9.0, 1.0/9.0, 1.0/9.0, 1.0/9.0,
1.0/36.0, 1.0/36.0, 1.0/36.0, 1.0/36.0);
var cx = array<f32,9>(0.0, 1.0, 0.0, -1.0, 0.0, 1.0, -1.0, -1.0, 1.0);
var cy = array<f32,9>(0.0, 0.0, 1.0, 0.0, -1.0, 1.0, 1.0, -1.0, -1.0);
var out: array<f32,9>;
for (var i = 0u; i < 9u; i = i + 1u) {
let quad = axx*(cx[i]*cx[i] - CS2) + 2.0*pxy*cx[i]*cy[i] + ayy*(cy[i]*cy[i] - CS2);
out[i] = w[i]*(rho + rho*(ux*cx[i] + uy*cy[i])/CS2 + quad/(2.0*CS2*CS2));
}
return out;
}
// third=1 — HRR: ряд Эрмита продолжается на 3-й порядок рекурсивно из неравновесных
// коэффициентов 2-го. Построчный перенос math::moment_wall.
fn moment_wall9(rho: f32, ux: f32, uy: f32, dudx: f32, dudy: f32, dvdx: f32, dvdy: f32,
b: f32, third: u32) -> array<f32,9> {
let pref = rho*CS2/(2.0*b);
let nxx = -pref*2.0*dudx;
let nyy = -pref*2.0*dvdy;
let nxy = -pref*(dudy + dvdx);
var out = grad_init9(rho, ux, uy,
rho*CS2 + rho*ux*ux + nxx,
rho*ux*uy + nxy,
rho*CS2 + rho*uy*uy + nyy);
if (third == 1u) {
let a3xxy = 2.0*ux*nxy + uy*nxx;
let a3xyy = 2.0*uy*nxy + ux*nyy;
let k = 1.0/(2.0*CS2*CS2*CS2);
var w = array<f32,9>(4.0/9.0, 1.0/9.0, 1.0/9.0, 1.0/9.0, 1.0/9.0,
1.0/36.0, 1.0/36.0, 1.0/36.0, 1.0/36.0);
var cx = array<f32,9>(0.0, 1.0, 0.0, -1.0, 0.0, 1.0, -1.0, -1.0, 1.0);
var cy = array<f32,9>(0.0, 0.0, 1.0, 0.0, -1.0, 1.0, 1.0, -1.0, -1.0);
for (var i = 0u; i < 9u; i = i + 1u) {
out[i] = out[i] + w[i]*k*((cx[i]*cx[i] - CS2)*cy[i]*a3xxy
+ cx[i]*(cy[i]*cy[i] - CS2)*a3xyy);
}
}
return out;
}
@compute @workgroup_size(64)
fn k_moment_wall(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(num_workgroups) nwg: vec3<u32>) {
let w = lin(gid, nwg);
if (w >= P.nwall) { return; }
let wn = wnodes[w];
let node = wn.x;
let first = wn.y;
let cnt = wn.z;
// целевая скорость (B 1): интерполяция между стенкой и дальним жидким соседом
var ux = 0.0;
var uy = 0.0;
for (var k = 0u; k < cnt; k = k + 1u) {
let L = links[first + k];
var fx = 0.0;
var fy = 0.0; // стенка неподвижна; дальнего соседа может не быть
if (solid[L.far] == 0u) {
let uf = u_at_t(L.far);
fx = uf.x;
fy = uf.y;
}
ux = ux + (L.q*fx) / (1.0 + L.q);
uy = uy + (L.q*fy) / (1.0 + L.q);
}
let inv = 1.0 / f32(cnt);
ux = ux * inv;
uy = uy * inv;
// целевая плотность (B 3): известные популяции плюс отскок недостающих
var missing = array<u32,9>(0u,0u,0u,0u,0u,0u,0u,0u,0u);
for (var k = 0u; k < cnt; k = k + 1u) { missing[links[first + k].ib] = 1u; }
var opp = array<u32,9>(0u, 3u, 4u, 1u, 2u, 7u, 8u, 5u, 6u);
var rho = 0.0;
for (var i = 0u; i < 9u; i = i + 1u) {
if (missing[i] == 1u) { rho = rho + pget(opp[i], node); }
else { rho = rho + fget(i, node); }
}
let g4 = grad_u_at_t(node);
var g = moment_wall9(rho, ux, uy, g4.x, g4.y, g4.z, g4.w,
beta[node % P.nx], select(0u, 1u, D.wall_mode == 2u));
for (var i = 0u; i < 9u; i = i + 1u) {
if (missing[i] == 1u) { fset(i, node, g[i]); }
}
}
// ───── AMR ─────
@compute @workgroup_size(64)
fn k_ghost(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(num_workgroups) nwg: vec3<u32>) {
let k = lin(gid, nwg);
if (k >= A.nghost) { return; }
let g = ghosts[k];
let w00 = (1.0 - g.tx)*(1.0 - g.ty);
let w10 = g.tx*(1.0 - g.ty);
let w01 = (1.0 - g.tx)*g.ty;
let w11 = g.tx*g.ty;
// отдельно интерполируются макропеременные, отдельно неравновесная часть (как в cpu.rs)
var ws = array<f32,4>(w00, w10, w01, w11);
var cs = array<u32,4>(g.c00, g.c10, g.c01, g.c11);
var rho = 0.0; var ux = 0.0; var uy = 0.0;
var neq = array<f32,9>(0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0);
for (var j = 0u; j < 4u; j = j + 1u) {
var cf: array<f32,9>;
for (var i = 0u; i < 9u; i = i + 1u) { cf[i] = amr_src[i*A.cstride + cs[j]]; }
let m = macros9(cf);
rho = rho + ws[j]*m.x;
ux = ux + ws[j]*m.y;
uy = uy + ws[j]*m.z;
var fe0 = feq9(m.x, m.y, m.z);
for (var i = 0u; i < 9u; i = i + 1u) { neq[i] = neq[i] + ws[j]*(cf[i] - fe0[i]); }
}
var fe = feq9(rho, ux, uy);
// неравновесная часть масштабируется: f^neq ~ tau*dt
for (var i = 0u; i < 9u; i = i + 1u) { amr_dst[k*9u + i] = fe[i] + A.r01*neq[i]; }
}
@compute @workgroup_size(64)
fn k_fill(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(num_workgroups) nwg: vec3<u32>) {
let k = lin(gid, nwg);
if (k >= A.nghost) { return; }
let g = ghosts[k];
// временная интерполяция рамки между состояниями L0 «до» и «после» шага
for (var i = 0u; i < 9u; i = i + 1u) {
amr_dst[i*A.fstride + g.fine] = (1.0 - A.w)*gh_a[k*9u + i] + A.w*gh_b[k*9u + i];
}
}
@compute @workgroup_size(64)
fn k_restrict(@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(num_workgroups) nwg: vec3<u32>) {
let k = lin(gid, nwg);
if (k >= A.nrestrict) { return; }
let pr = rest[k];
var ff: array<f32,9>;
for (var i = 0u; i < 9u; i = i + 1u) { ff[i] = amr_src[i*A.fstride + pr.y]; }
let m = macros9(ff);
var fe = feq9(m.x, m.y, m.z);
for (var i = 0u; i < 9u; i = i + 1u) {
amr_dst[i*A.cstride + pr.x] = fe[i] + A.rfc*(ff[i] - fe[i]);
}
}
"#;
// ─────────────────────────────────────────────────────────────────────────────
// Уровень на устройстве
// ─────────────────────────────────────────────────────────────────────────────
struct GpuLevel {
nx: usize,
ny: usize,
n: usize,
/// шаг между направлениями в `f`; см. dir_stride
stride: usize,
f: wgpu::Buffer,
/// Стабилизатор γ поузлово — нужен для картинки; забирается с устройства как есть.
gam: wgpu::Buffer,
bind: wgpu::BindGroup,
/// Группа 3: индекс граничных узлов для моментной стенки.
wall_bind: wgpu::BindGroup,
nwall: u32,
/// число рабочих групп редукции статистики (оно же длина буфера частичных сумм)
parts_count: u32,
nlinks: u32,
}
impl GpuLevel {
fn link_groups(&self) -> u32 {
ceil_div(self.nlinks.max(1), WG)
}
}
/// Переложить готовое стартовое поле (построенное общим кодом в `cpu::initial_field`)
/// из AoS в раскладку SoA, которой пользуется GPU.
/// Выравнивание шага между направлениями, во ФЛОАТАХ.
///
/// Смещение каждой привязки обязано быть кратно `min_storage_buffer_offset_alignment`,
/// а он равен 256 байтам у всех известных реализаций — это 64 значения f32. Шаг
/// выравнивается всегда, даже когда привязка одна: так раскладка одна на оба варианта
/// шейдера, и не надо помнить, какой из них сейчас собран. Перерасход памяти — меньше
/// сотой доли процента.
const DIR_ALIGN: usize = 64;
/// Расстояние между началами соседних направлений в буфере популяций, во флоатах.
fn dir_stride(n: usize) -> usize {
n.div_ceil(DIR_ALIGN) * DIR_ALIGN
}
fn to_soa(f: &[[R; math::Q]], stride: usize) -> Vec<f32> {
let n = f.len();
debug_assert!(stride >= n);
let mut v = vec![0.0f32; 9 * stride];
for (k, c) in f.iter().enumerate() {
for i in 0..math::Q {
v[i * stride + k] = c[i] as f32;
}
}
v
}
/// Залить уровень на устройство: поля, маски, линки, β и групповая привязка.
fn make_level(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
nx: usize,
ny: usize,
geom: &cpu::Geom,
beta: &[f32],
probe_node: u32,
flags: u32,
init_field: &[[R; math::Q]],
wall_layout: &wgpu::BindGroupLayout,
split: bool,
) -> GpuLevel {
let n = nx * ny;
let stride = dir_stride(n);
let init = to_soa(init_field, stride);
let mkf = |label: &str| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: bytemuck::cast_slice(&init),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC
| wgpu::BufferUsages::COPY_DST,
})
};
let f = mkf("f");
let post = mkf("post");
let gam = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("gamma"),
contents: bytemuck::cast_slice(&vec![2.0f32; n]),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
});
let solid: Vec<u32> = geom.solid.iter().map(|s| *s as u32).collect();
let solid_buf = storage_init(device, "solid", bytemuck::cast_slice(&solid));
let links: Vec<GLink> = geom
.links
.iter()
.map(|l| GLink {
node: l.node,
far: l.far,
i: l.i as u32,
ib: l.ib as u32,
kind: match l.kind {
math::LinkKind::Near => 0,
math::LinkKind::Far => 1,
math::LinkKind::Simple => 2,
},
q: l.q as f32,
body: l.body as u32,
_p: 0,
})
.collect();
// Пустая сцена (эталонные течения) даёт нулевой список линков, а шейдер всё равно
// объявляет массив структур: буфер обязан вмещать хотя бы один элемент, иначе валидация
// ругается на несоответствие размера. Читать его при этом некому — nlinks = 0.
let links_pad = if links.is_empty() { vec![GLink::zeroed()] } else { links.clone() };
let links_buf = storage_init(device, "links", bytemuck::cast_slice(&links_pad));
let beta_buf = storage_init(device, "beta", bytemuck::cast_slice(beta));
// индекс граничных узлов: (узел, смещение первого линка, число линков, —)
let wnodes: Vec<[u32; 4]> = geom
.wall_nodes
.iter()
.map(|w| [w.node, w.first, w.count as u32, 0])
.collect();
let nwall = wnodes.len() as u32;
let wnodes_pad = if wnodes.is_empty() { vec![[0u32; 4]] } else { wnodes.clone() };
let wall_buf = storage_init(device, "wall nodes", bytemuck::cast_slice(&wnodes_pad));
let wall_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("wall"),
layout: wall_layout,
entries: &[bind(0, &wall_buf)],
});
let parts_count = ceil_div(n as u32, WG);
let parts = storage(device, "parts", (parts_count as u64) * 32);
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("level params"),
contents: bytemuck::bytes_of(&LevelParams {
n: n as u32,
nx: nx as u32,
ny: ny as u32,
nlinks: links.len() as u32,
bcx: geom.body_cx as f32,
bcy: geom.body_cy as f32,
probe_node,
flags,
nwall,
stride: stride as u32,
_pad: [0; 2],
}),
usage: wgpu::BufferUsages::UNIFORM,
});
// Общая часть связки одинакова в обоих вариантах; различаются только популяции.
let mut entries = vec![
bind(2, &gam),
bind(3, &solid_buf),
bind(4, &links_buf),
bind(5, &beta_buf),
bind(6, &parts),
bind(7, &params),
];
if split {
// Тот же буфер, но показанный шейдеру по одному направлению: каждая привязка
// укладывается в n*4 байта вместо n*9*4, и предел драйвера на размер ОДНОЙ
// привязки перестаёт быть потолком для размера сетки.
for i in 0..math::Q {
let off = (i * stride * 4) as u64;
let len = (n * 4) as u64;
entries.push(bind_range(POP_F0 + i as u32, &f, off, len));
entries.push(bind_range(POP_P0 + i as u32, &post, off, len));
}
} else {
entries.push(bind(0, &f));
entries.push(bind(1, &post));
}
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("level"),
layout,
entries: &entries,
});
GpuLevel { nx, ny, n, stride, f, gam, bind, wall_bind, nwall, parts_count, nlinks: links.len() as u32 }
}
// ─────────────────────────────────────────────────────────────────────────────
// Симуляция
// ─────────────────────────────────────────────────────────────────────────────
pub struct Sim {
spec: Spec,
device: wgpu::Device,
queue: wgpu::Queue,
adapter_name: String,
l0: GpuLevel,
l1: Option<GpuLevel>,
pre: wgpu::Buffer,
dyn_buf: wgpu::Buffer,
results_buf: wgpu::Buffer,
staging: wgpu::Buffer,
/// по одной группе на подшаг — различаются только индексом подшага в `Substep`
dyn_binds: Vec<wgpu::BindGroup>,
amr: Option<AmrRes>,
pipes: Pipes,
readback: wgpu::Buffer,
fluid_count: R,
step_index: u64,
d_ref: R,
/// Номера шагов, посчитанных на устройстве, но ещё не прочитанных на хост.
/// Длина = занятые слоты истории.
pending: Vec<u64>,
}
struct AmrRes {
nghost: u32,
nrestrict: u32,
bg_ghost_old: wgpu::BindGroup,
bg_ghost_new: wgpu::BindGroup,
bg_fill: Vec<wgpu::BindGroup>,
bg_restrict: wgpu::BindGroup,
}
struct Pipes {
collide: wgpu::ComputePipeline,
stream: wgpu::ComputePipeline,
bouzidi: wgpu::ComputePipeline,
moment_wall: wgpu::ComputePipeline,
walls: wgpu::ComputePipeline,
channel: wgpu::ComputePipeline,
force: wgpu::ComputePipeline,
stats1: wgpu::ComputePipeline,
stats2: wgpu::ComputePipeline,
ghost: wgpu::ComputePipeline,
fill: wgpu::ComputePipeline,
restrict: wgpu::ComputePipeline,
empty_bg: wgpu::BindGroup,
}
impl Sim {
pub fn new(spec: Spec) -> Result<Sim, String> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
// Флаги читаются из окружения, и нужно это ровно ради одного —
// WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER.
//
// По умолчанию wgpu ПРЯЧЕТ адаптеры, не прошедшие набор тестов соответствия
// Vulkan («Adapter is not Vulkan compliant, hiding adapter»), и делает это
// молча: request_adapter просто возвращает None. Под это правило попадает
// dzn (Dozen) — драйвер Mesa, транслирующий Vulkan в D3D12. В WSL2 он
// единственный способ добраться до карты: Linux-драйвера Vulkan у NVIDIA
// там нет, устройство отдаётся через /dev/dxg по протоколу WDDM.
//
// Поведение по умолчанию НЕ меняется: без переменной окружения
// несоответствующие адаптеры по-прежнему скрыты. Переменная — осознанное
// согласие считать на непроверенном драйвере, и прежде чем на нём считать,
// надо прогнать bench/parity.py: он сверяет GPU-путь с CPU в двойной
// точности на течениях с точным решением.
flags: wgpu::InstanceFlags::from_build_config().with_env(),
..Default::default()
});
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: None,
force_fallback_adapter: false,
}))
.ok_or("подходящий GPU-адаптер не найден (нужен Vulkan, DX12 или Metal)")?;
let info = adapter.get_info();
let adapter_name = format!("{} ({:?})", info.name, info.backend);
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("kbc2d"),
required_features: wgpu::Features::empty(),
// берём то, что реально умеет адаптер: связке уровня нужно 8 storage-биндингов,
// downlevel-профиль даёт всего 4
required_limits: adapter.limits(),
memory_hints: wgpu::MemoryHints::Performance,
},
None,
))
.map_err(|e| format!("не удалось получить устройство: {e}"))?;
// Моментная стенка добавляет к связке уровня ещё один storage-биндинг (индекс
// граничных узлов), итого 9 против 8, гарантируемых лимитами по умолчанию. На
// десктопных адаптерах их обычно >= 16, но проверить надо явно.
if spec.wall.is_moment_based() {
let lim = adapter.limits().max_storage_buffers_per_shader_stage;
if lim < 9 {
return Err(format!(
"адаптер даёт только {lim} storage-биндингов на стадию, моментной стенке \
нужно 9. Запусти с --wall bouzidi либо --backend cpu"
));
}
}
// Влезает ли массив популяций в ОДНУ привязку. Считаем по самому крупному
// уровню: у патча измельчения своих узлов может оказаться больше, чем у L0.
let max_nodes = {
let n0 = spec.nx * spec.ny;
let n1 = match (spec.refine > 1, spec.patch) {
(true, Some((ax, bx, ay, by))) => {
(spec.refine * (bx - ax) + 1) * (spec.refine * (by - ay) + 1)
}
_ => 0,
};
n0.max(n1)
};
let need = (math::Q * dir_stride(max_nodes) * 4) as u64;
// Переменная окружения нужна не для работы, а для проверки: без неё раздельный
// вариант включается только на драйверах с малым пределом, и сверить два
// варианта на одной карте было бы нечем.
let split = need > adapter.limits().max_storage_buffer_binding_size as u64
|| std::env::var("KBC2D_SPLIT_POPULATIONS").is_ok_and(|v| v != "0");
if split {
// Шаг между направлениями выровнен на DIR_ALIGN значений f32. Если адаптер
// требует более крупного выравнивания смещений, раздельные привязки собрать
// нельзя — лучше сказать это прямо, чем ловить невнятную ошибку валидации.
let align = adapter.limits().min_storage_buffer_offset_alignment as usize;
if (DIR_ALIGN * 4) % align != 0 {
return Err(format!(
"адаптер требует выравнивания смещений на {align} байт, а раскладка популяций рассчитана на {}. Считай на CPU либо на другом драйвере",
DIR_ALIGN * 4
));
}
let per = (dir_stride(max_nodes) * 4) as u64;
if per > adapter.limits().max_storage_buffer_binding_size as u64 {
return Err(format!(
"адаптер ограничивает привязку {} МиБ, а одному направлению нужно {} МиБ. Сетка слишком крупная для этого драйвера — считай на CPU либо на драйвере без такого предела",
adapter.limits().max_storage_buffer_binding_size / 1048576,
per / 1048576
));
}
}
// ── топология берётся из процессорного бэкенда, а не строится заново ──
let geom0 = cpu::Geom::build(spec.nx, spec.ny, &spec.scene);
let fluid_count = geom0.solid.iter().filter(|s| !**s).count() as R;
let beta0: Vec<f32> =
math::beta_profile(spec.nx, spec.beta0, spec.sponge_in, spec.sponge_len, spec.sponge_mult)
.into_iter()
.map(|v| v as f32)
.collect();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("kbc2d.wgsl"),
source: wgpu::ShaderSource::Wgsl(Cow::Owned(build_shader(split))),
});
let bgl_level = level_layout(&device, split);
let bgl_dyn = dyn_layout(&device);
let bgl_amr = amr_layout(&device);
let bgl_wall = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("wall"),
entries: &[ro(0)],
});
let bgl_empty = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("empty"),
entries: &[],
});
let pl_level = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("level"),
bind_group_layouts: &[&bgl_level, &bgl_dyn],
push_constant_ranges: &[],
});
let pl_wall = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("moment wall"),
bind_group_layouts: &[&bgl_level, &bgl_dyn, &bgl_empty, &bgl_wall],
push_constant_ranges: &[],
});
let pl_amr = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("amr"),
bind_group_layouts: &[&bgl_empty, &bgl_empty, &bgl_amr],
push_constant_ranges: &[],
});
let mk = |layout: &wgpu::PipelineLayout, entry: &str| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: Some(layout),
module: &module,
entry_point: entry,
compilation_options: Default::default(),
cache: None,
})
};
// ── общие буферы ──
let dyn_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dyn"),
size: std::mem::size_of::<Dyn>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// история на HIST шагов + рабочие слоты силы на 8 подшагов
let hist_bytes = (HIST * std::mem::size_of::<Results>()) as u64;
let results_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("results"),
size: hist_bytes + 8 * 4 * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("staging"),
size: hist_bytes,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let nsub = spec.refine.max(1);
let dyn_binds: Vec<wgpu::BindGroup> = (0..nsub)
.map(|s| {
let sb = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("substep"),
contents: bytemuck::bytes_of(&Substep { idx: s as u32, _p: [0; 3] }),
usage: wgpu::BufferUsages::UNIFORM,
});
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dyn"),
layout: &bgl_dyn,
entries: &[bind(0, &dyn_buf), bind(1, &sb), bind(2, &results_buf)],
})
})
.collect();
// стартовое поле: либо сразу набегающий поток, либо покой (см. cpu.rs)
let u0 = if spec.init_uniform {
let (sn, cs) = spec.flow_angle.sin_cos();
(spec.units.u_lat * cs, spec.units.u_lat * sn)
} else {
(0.0, 0.0)
};
// ── зонд ──
let (px, py) = spec.probe;
let probe_on_fine = matches!(spec.patch, Some((ax, bx, ay, by))
if px >= ax && px <= bx && py >= ay && py <= by)
&& spec.refine > 1;
// ── уровень 0 ──
let probe0 = if probe_on_fine { 0 } else { py * spec.nx + px };
let flags0 = 1 | if probe_on_fine { 0 } else { 2 };
let l0 = make_level(
&device,
&bgl_level,
spec.nx,
spec.ny,
&geom0,
&beta0,
probe0 as u32,
flags0,
&cpu::initial_field(&cpu::Init {
case: spec.case,
nx: spec.nx,
ny: spec.ny,
u0,
beta: spec.beta0,
scene: Some(&spec.scene),
taper: if spec.init_uniform { spec.init_taper } else { 0.0 },
}),
&bgl_wall,
split,
);
let pre = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("pre"),
size: (math::Q * l0.stride * 4) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// ── уровень 1 и AMR ──
let mut l1 = None;
let mut amr = None;
if spec.refine > 1 {
let (ax, bx, ay, by) = spec.patch.ok_or("refine > 1 требует патч")?;
let r = spec.refine;
let scene1 = spec.scene.refined(r as R, ax as R, ay as R);
let nfx = r * (bx - ax) + 1;
let nfy = r * (by - ay) + 1;
let geom1 = cpu::Geom::build(nfx, nfy, &scene1);
let tau0 = 1.0 / (2.0 * spec.beta0);
let tau1 = r as R * (tau0 - 0.5) + 0.5;
let beta1 = vec![(1.0 / (2.0 * tau1)) as f32; nfx];
let r01 = tau1 / (r as R * tau0);
let patch = cpu::Patch::new(&spec, &geom0, &geom1.solid, r01);
let probe1 = if probe_on_fine { ((py - ay) * r) * nfx + (px - ax) * r } else { 0 };
let flags1 = if probe_on_fine { 2 } else { 0 };
let lvl1 =
make_level(
&device, &bgl_level, nfx, nfy, &geom1, &beta1, probe1 as u32, flags1,
&cpu::initial_field(&cpu::Init {
case: spec.case,
nx: nfx,
ny: nfy,
u0,
beta: 1.0 / (2.0 * tau1),
scene: Some(&scene1),
taper: if spec.init_uniform { spec.init_taper * r as R } else { 0.0 },
}),
&bgl_wall,
split,
);
let ghosts: Vec<GGhost> = patch
.ghosts()
.iter()
.map(|g| GGhost {
fine: g.fine,
c00: g.c00,
c10: g.c10,
c01: g.c01,
c11: g.c11,
tx: g.tx as f32,
ty: g.ty as f32,
_p: 0,
})
.collect();
let rest: Vec<[u32; 2]> =
patch.restrict_pairs().iter().map(|&(c, fi)| [c, fi]).collect();
let ghost_bytes = (ghosts.len() * 9 * 4) as u64;
let gh_old = storage(&device, "gh_old", ghost_bytes);
let gh_new = storage(&device, "gh_new", ghost_bytes);
let ghosts_buf = storage_init(&device, "ghosts", bytemuck::cast_slice(&ghosts));
let rest_buf = storage_init(&device, "rest", bytemuck::cast_slice(&rest));
let dummy = storage(&device, "dummy", 16);
let base = AmrParams {
nghost: ghosts.len() as u32,
nrestrict: rest.len() as u32,
cstride: l0.stride as u32,
fstride: lvl1.stride as u32,
r01: r01 as f32,
rfc: (1.0 / r01) as f32,
w: 0.0,
_pad: 0.0,
};
let mk_amr_bg = |src: &wgpu::Buffer,
dst: &wgpu::Buffer,
a: &wgpu::Buffer,
b: &wgpu::Buffer,
params: AmrParams| {
let ub = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("amr params"),
contents: bytemuck::bytes_of(&params),
usage: wgpu::BufferUsages::UNIFORM,
});
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("amr"),
layout: &bgl_amr,
entries: &[
bind(0, src),
bind(1, dst),
bind(2, &ghosts_buf),
bind(3, a),
bind(4, b),
bind(5, &rest_buf),
bind(6, &ub),
],
})
};
let bg_ghost_old = mk_amr_bg(&pre, &gh_old, &dummy, &dummy, base);
let bg_ghost_new = mk_amr_bg(&l0.f, &gh_new, &dummy, &dummy, base);
let bg_fill: Vec<wgpu::BindGroup> = (0..r)
.map(|s| {
let mut p = base;
p.w = ((s + 1) as R / r as R) as f32;
mk_amr_bg(&dummy, &lvl1.f, &gh_old, &gh_new, p)
})
.collect();
let bg_restrict = mk_amr_bg(&lvl1.f, &l0.f, &dummy, &dummy, base);
amr = Some(AmrRes {
nghost: ghosts.len() as u32,
nrestrict: rest.len() as u32,
bg_ghost_old,
bg_ghost_new,
bg_fill,
bg_restrict,
});
l1 = Some(lvl1);
}
let pipes = Pipes {
collide: mk(&pl_level, "k_collide"),
stream: mk(&pl_level, "k_stream"),
bouzidi: mk(&pl_level, "k_bouzidi"),
moment_wall: mk(&pl_wall, "k_moment_wall"),
walls: mk(&pl_level, "k_walls"),
channel: mk(&pl_level, "k_channel"),
force: mk(&pl_level, "k_force"),
stats1: mk(&pl_level, "k_stats1"),
stats2: mk(&pl_level, "k_stats2"),
ghost: mk(&pl_amr, "k_ghost"),
fill: mk(&pl_amr, "k_fill"),
restrict: mk(&pl_amr, "k_restrict"),
empty_bg: device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("empty"),
layout: &bgl_empty,
entries: &[],
}),
};
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (math::Q * l0.stride * 4) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let d_ref = match spec.patch {
Some(_) if spec.refine > 1 => spec.scene.ref_size() * spec.refine as R,
_ => spec.scene.ref_size(),
};
Ok(Sim {
spec,
device,
queue,
adapter_name,
l0,
l1,
pre,
dyn_buf,
results_buf,
staging,
dyn_binds,
amr,
pipes,
readback,
fluid_count,
step_index: 0,
pending: Vec::with_capacity(HIST),
d_ref,
})
}
pub fn name(&self) -> &'static str {
// сигнатура бэкенда фиксирована; конкретный адаптер печатается отдельно
Box::leak(format!("GPU: {} , f32", self.adapter_name).into_boxed_str())
}
pub fn force_ref_size(&self) -> R {
self.d_ref
}
fn inlet(&self, t: u64) -> (R, R) {
let sp = &self.spec;
let ramp = if sp.init_uniform {
1.0
} else {
math::smoothstep(t as R / sp.ramp.max(1) as R)
};
let u = sp.units.u_lat * ramp;
let (s, c) = sp.flow_angle.sin_cos();
let mut uy = u * s;
if sp.pert_dur > 0 && t >= sp.ramp && t < sp.ramp + sp.pert_dur {
let ph = (t - sp.ramp) as R / sp.pert_dur as R;
uy += sp.pert_amp * sp.units.u_lat * (std::f64::consts::PI * ph).sin();
}
(u * c, uy)
}
/// Посчитать один шаг. Результаты не читаются сразу: они копятся в истории на устройстве
/// и попадают в `out` пачкой — либо когда история заполнится, либо по явному `flush`.
pub fn advance(&mut self, out: &mut Vec<StepRec>) {
let t = self.step_index;
let slot = self.pending.len() as u32;
let (ux_in, uy_in) = self.inlet(t);
let refine = self.spec.refine.max(1);
let moment_wall = self.spec.wall.is_moment_based();
let channel = self.spec.case == Case::Channel;
self.queue.write_buffer(
&self.dyn_buf,
0,
bytemuck::bytes_of(&Dyn {
ux_in: ux_in as f32,
uy_in: uy_in as f32,
rho_out: 1.0,
kbc_model: (self.spec.kbc_model == math::KbcModel::N2) as u32,
outlet_extrap: self.spec.outlet_extrapolate as u32,
nparts: self.l0.parts_count,
refine: refine as u32,
collision: (self.spec.collision == Collision::Bgk) as u32,
slot,
wall_mode: match self.spec.wall {
math::WallModel::Bouzidi => 0,
math::WallModel::Grad => 1,
math::WallModel::Hrr => 2,
math::WallModel::Staircase => 3,
},
_pad: [0; 2],
}),
);
let mut enc = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("step") });
// «до» — состояние L0 перед столкновением, нужно как старый край рамки патча
if self.amr.is_some() {
enc.copy_buffer_to_buffer(&self.l0.f, 0, &self.pre, 0, (math::Q * self.l0.stride * 4) as u64);
}
{
let mut p = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("L0"),
timestamp_writes: None,
});
p.set_bind_group(0, &self.l0.bind, &[]);
p.set_bind_group(1, &self.dyn_binds[0], &[]);
let ncell = ceil_div(self.l0.n as u32, WG);
p.set_pipeline(&self.pipes.collide);
dispatch(&mut p, ncell);
p.set_pipeline(&self.pipes.stream);
dispatch(&mut p, ncell);
// Эталонные течения статей периодичны по обеим осям: ГУ не накладываются вовсе.
if channel {
if moment_wall {
p.set_bind_group(2, &self.pipes.empty_bg, &[]);
p.set_bind_group(3, &self.l0.wall_bind, &[]);
p.set_pipeline(&self.pipes.moment_wall);
dispatch(&mut p, ceil_div(self.l0.nwall.max(1), WG));
} else {
p.set_pipeline(&self.pipes.bouzidi);
dispatch(&mut p, self.l0.link_groups());
}
// порядок обязателен: стенки снимают заворот по y, затем Zou-He — по x
p.set_pipeline(&self.pipes.walls);
dispatch(&mut p, ceil_div(self.l0.nx as u32, WG));
p.set_pipeline(&self.pipes.channel);
dispatch(&mut p, ceil_div(self.l0.ny as u32, WG));
}
}
if let (Some(l1), Some(a)) = (self.l1.as_ref(), self.amr.as_ref()) {
{
let mut p = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("ghost"),
timestamp_writes: None,
});
p.set_bind_group(0, &self.pipes.empty_bg, &[]);
p.set_bind_group(1, &self.pipes.empty_bg, &[]);
p.set_pipeline(&self.pipes.ghost);
p.set_bind_group(2, &a.bg_ghost_old, &[]);
dispatch(&mut p, ceil_div(a.nghost, WG));
p.set_bind_group(2, &a.bg_ghost_new, &[]);
dispatch(&mut p, ceil_div(a.nghost, WG));
}
let ncell1 = ceil_div(l1.n as u32, WG);
let nlink1 = l1.link_groups();
for s in 0..refine {
{
let mut p = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("L1"),
timestamp_writes: None,
});
p.set_bind_group(0, &l1.bind, &[]);
p.set_bind_group(1, &self.dyn_binds[s], &[]);
p.set_pipeline(&self.pipes.collide);
dispatch(&mut p, ncell1);
p.set_pipeline(&self.pipes.stream);
dispatch(&mut p, ncell1);
if channel {
if moment_wall {
p.set_bind_group(2, &self.pipes.empty_bg, &[]);
p.set_bind_group(3, &l1.wall_bind, &[]);
p.set_pipeline(&self.pipes.moment_wall);
dispatch(&mut p, ceil_div(l1.nwall.max(1), WG));
} else {
p.set_pipeline(&self.pipes.bouzidi);
dispatch(&mut p, nlink1);
}
}
// силу снимаем на каждом подшаге, усредняется она в k_stats2
p.set_pipeline(&self.pipes.force);
p.dispatch_workgroups(1, 1, 1);
}
{
let mut p = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("fill"),
timestamp_writes: None,
});
p.set_bind_group(0, &self.pipes.empty_bg, &[]);
p.set_bind_group(1, &self.pipes.empty_bg, &[]);
p.set_bind_group(2, &a.bg_fill[s], &[]);
p.set_pipeline(&self.pipes.fill);
dispatch(&mut p, ceil_div(a.nghost, WG));
}
}
{
let mut p = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("restrict"),
timestamp_writes: None,
});
p.set_bind_group(0, &self.pipes.empty_bg, &[]);
p.set_bind_group(1, &self.pipes.empty_bg, &[]);
p.set_bind_group(2, &a.bg_restrict, &[]);
p.set_pipeline(&self.pipes.restrict);
dispatch(&mut p, ceil_div(a.nrestrict, WG));
}
} else {
let mut p = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("force L0"),
timestamp_writes: None,
});
p.set_bind_group(0, &self.l0.bind, &[]);
p.set_bind_group(1, &self.dyn_binds[0], &[]);
p.set_pipeline(&self.pipes.force);
p.dispatch_workgroups(1, 1, 1);
}
{
let mut p = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("stats"),
timestamp_writes: None,
});
p.set_bind_group(1, &self.dyn_binds[0], &[]);
p.set_bind_group(0, &self.l0.bind, &[]);
p.set_pipeline(&self.pipes.stats1);
dispatch(&mut p, self.l0.parts_count);
if let Some(l1) = self.l1.as_ref() {
// на тонком уровне stats1 нужен только чтобы снять зонд (флаг записи сумм снят)
p.set_bind_group(0, &l1.bind, &[]);
dispatch(&mut p, ceil_div(l1.n as u32, WG));
p.set_bind_group(0, &self.l0.bind, &[]);
}
p.set_pipeline(&self.pipes.stats2);
p.dispatch_workgroups(1, 1, 1);
}
self.queue.submit(Some(enc.finish()));
self.pending.push(t);
self.step_index += 1;
if self.pending.len() == HIST {
self.flush(out);
}
}
/// Дочитать всё, что уже посчитано устройством. Обязательно вызывать перед тем, как
/// смотреть на поля (кадр гифки, проверка на NaN) — иначе записи отстанут от состояния.
pub fn flush(&mut self, out: &mut Vec<StepRec>) {
let n = self.pending.len();
if n == 0 {
return;
}
let bytes = (n * std::mem::size_of::<Results>()) as u64;
let mut enc = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("flush") });
enc.copy_buffer_to_buffer(&self.results_buf, 0, &self.staging, 0, bytes);
self.queue.submit(Some(enc.finish()));
let slice = self.staging.slice(..bytes);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
self.device.poll(wgpu::Maintain::Wait);
let got: Vec<Results> = match rx.recv() {
Ok(Ok(())) => {
let data = slice.get_mapped_range();
bytemuck::cast_slice::<u8, Results>(&data[..bytes as usize]).to_vec()
}
_ => vec![Results::default(); n],
};
self.staging.unmap();
for (i, &t) in self.pending.iter().enumerate() {
out.push(self.make_rec(t, &got[i]));
}
self.pending.clear();
}
fn make_rec(&self, t: u64, res: &Results) -> StepRec {
let cnt = if res.cnt > 0.0 { res.cnt as R } else { self.fluid_count };
let mut body = [[0.0 as R; 3]; crate::MAX_BODY_BUCKETS];
for b in 0..crate::MAX_BODY_BUCKETS.min(4) {
for c in 0..3 {
body[b][c] = res.body[b * 3 + c] as R;
}
}
StepRec {
step: t,
fx: res.fx as R,
fy: res.fy as R,
tz: res.tz as R,
body,
uy_probe: res.uy_probe as R,
rho_mean: res.rho_sum as R / self.fluid_count,
max_u: res.max_u as R,
gamma_mean: res.g_sum as R / cnt,
gamma_min: res.g_min as R,
gamma_max: res.g_max as R,
degenerate_frac: res.degen as R / cnt,
xi_negative_frac: res.xineg as R / cnt,
}
}
/// Скачать популяции L0 на хост (нужно для кадров и проверки на NaN).
fn download_l0(&self) -> Vec<f32> {
self.download(&self.l0.f, (math::Q * self.l0.stride * 4) as u64)
}
/// Скачать произвольный буфер уровня. Буфер приёма выделен один раз при сборке под самый
/// большой запрос (популяции L0); для меньших читается только его начало.
fn download(&self, src: &wgpu::Buffer, bytes: u64) -> Vec<f32> {
let rb = &self.readback;
let mut enc = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("dl") });
enc.copy_buffer_to_buffer(src, 0, rb, 0, bytes);
self.queue.submit(Some(enc.finish()));
let slice = rb.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
self.device.poll(wgpu::Maintain::Wait);
let out = match rx.recv() {
Ok(Ok(())) => {
let m = slice.get_mapped_range();
bytemuck::cast_slice::<u8, f32>(&m[..bytes as usize]).to_vec()
}
_ => vec![f32::NAN; (bytes / 4) as usize],
};
rb.unmap();
out
}
/// Полное поле скорости уровня L0 — для метрик эталонных течений и радиуса влияния.
pub fn sample_velocity(&self) -> (Vec<R>, Vec<R>) {
let n = self.l0.n;
let stride = self.l0.stride;
let raw = self.download_l0();
let mut ux = Vec::with_capacity(n);
let mut uy = Vec::with_capacity(n);
for k in 0..n {
let c: [R; 9] = std::array::from_fn(|i| raw[i * stride + k] as R);
let (_, a, b) = math::macros(&c);
ux.push(a);
uy.push(b);
}
(ux, uy)
}
/// Срез вдоль осевой линии: (ρ, u_x) по столбцам. На GPU это полное скачивание поля,
/// поэтому x–t диагностика включается редким шагом и только там, где нужна.
pub fn sample_centerline(&self) -> (Vec<R>, Vec<R>) {
let (nx, ny) = (self.l0.nx, self.l0.ny);
let stride = self.l0.stride;
let raw = self.download_l0();
let y = ny / 2;
let mut rho = Vec::with_capacity(nx);
let mut ux = Vec::with_capacity(nx);
for x in 0..nx {
let k = y * nx + x;
let c: [R; 9] = std::array::from_fn(|i| raw[i * stride + k] as R);
let (r, a, _) = math::macros(&c);
rho.push(r);
ux.push(a);
}
(rho, ux)
}
pub fn is_finite(&self) -> bool {
self.download_l0().iter().all(|v| v.is_finite())
}
pub fn sample_field(&self, kind: FieldKind) -> (Vec<R>, Vec<bool>) {
let (nx, ny, n) = (self.l0.nx, self.l0.ny, self.l0.n);
let stride = self.l0.stride;
let raw = self.download_l0();
let node = |k: usize| -> [R; 9] {
std::array::from_fn(|i| raw[i * stride + k] as R)
};
let mut out = vec![0.0; n];
match kind {
FieldKind::Speed => {
for k in 0..n {
let (_, ux, uy) = math::macros(&node(k));
out[k] = (ux * ux + uy * uy).sqrt();
}
}
FieldKind::Density => {
for k in 0..n {
out[k] = math::macros(&node(k)).0;
}
}
FieldKind::Vorticity => {
let mut ux = vec![0.0; n];
let mut uy = vec![0.0; n];
for k in 0..n {
let (_, a, b) = math::macros(&node(k));
ux[k] = a;
uy[k] = b;
}
for y in 0..ny {
for x in 0..nx {
let xp = (x + 1).min(nx - 1);
let xm = x.saturating_sub(1);
let yp = (y + 1).min(ny - 1);
let ym = y.saturating_sub(1);
let dvdx = (uy[y * nx + xp] - uy[y * nx + xm]) / (xp - xm).max(1) as R;
let dudy = (ux[yp * nx + x] - ux[ym * nx + x]) / (yp - ym).max(1) as R;
out[y * nx + x] = dvdx - dudy;
}
}
}
FieldKind::Gamma => {
// берём ровно то, что посчитало устройство: пересчёт на хосте с базовым β
// соврал бы всюду, где β — поле (включённая губка)
let g = self.download(&self.l0.gam, (n * 4) as u64);
for k in 0..n {
out[k] = g[k] as R;
}
}
}
let solid = cpu::Geom::build(nx, ny, &self.spec.scene).solid;
(out, solid)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Мелкие помощники
// ─────────────────────────────────────────────────────────────────────────────
/// Предел числа рабочих групп на измерение — 65535 (`maxComputeWorkgroupsPerDimension`).
/// Всё, что не влезло, раскладывается по второму измерению; шейдеры собирают линейный
/// индекс через lin()/wlin(). Хвост за пределами поля отсекается обычной проверкой границ.
fn wg_grid(groups: u32) -> (u32, u32) {
const MAX: u32 = 65535;
if groups <= MAX { (groups.max(1), 1) } else { (MAX, ceil_div(groups, MAX)) }
}
/// Запустить ядро на `groups` рабочих групп, разложив их по двум измерениям.
fn dispatch(p: &mut wgpu::ComputePass<'_>, groups: u32) {
let (gx, gy) = wg_grid(groups);
p.dispatch_workgroups(gx, gy, 1);
}
fn ceil_div(a: u32, b: u32) -> u32 {
(a + b - 1) / b
}
fn bind(binding: u32, buf: &wgpu::Buffer) -> wgpu::BindGroupEntry<'_> {
wgpu::BindGroupEntry { binding, resource: buf.as_entire_binding() }
}
/// Привязать кусок буфера. Смещение обязано быть кратно
/// `min_storage_buffer_offset_alignment` — этим и занят `dir_stride`.
fn bind_range(binding: u32, buf: &wgpu::Buffer, offset: u64, size: u64) -> wgpu::BindGroupEntry<'_> {
wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: buf,
offset,
size: std::num::NonZeroU64::new(size),
}),
}
}
fn storage(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(16),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
fn storage_init(device: &wgpu::Device, label: &str, data: &[u8]) -> wgpu::Buffer {
let pad;
let data = if data.is_empty() {
pad = [0u8; 16];
&pad[..]
} else {
data
};
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: data,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
})
}
fn ro(binding: u32) -> wgpu::BindGroupLayoutEntry {
entry(binding, wgpu::BufferBindingType::Storage { read_only: true })
}
fn rw(binding: u32) -> wgpu::BindGroupLayoutEntry {
entry(binding, wgpu::BufferBindingType::Storage { read_only: false })
}
fn un(binding: u32) -> wgpu::BindGroupLayoutEntry {
entry(binding, wgpu::BufferBindingType::Uniform)
}
fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None },
count: None,
}
}
/// Номера привязок раздельных популяций. Взяты выше занятых (2..7), чтобы остальная
/// часть связки не меняла нумерацию между вариантами шейдера.
const POP_F0: u32 = 8;
const POP_P0: u32 = 17;
/// Собрать WGSL под конкретный адаптер.
///
/// `split` — раскладывать ли популяции по девяти привязкам вместо одной общей.
///
/// Зачем это вообще. Драйверы ограничивают размер ОДНОЙ привязки storage-буфера, и у
/// разных он разный: нативные дают гигабайты, а dzn (трансляция Vulkan в D3D12 —
/// единственный способ добраться до карты из WSL2, где Linux-драйвера Vulkan у NVIDIA
/// нет) даёт 128 МиБ. Массив популяций занимает nx*ny*9*4 байта, так что на dzn потолок
/// выходит 3.73 млн узлов — сетка 2048x2048 уже не проходит. При этом сам БУФЕР держать
/// разрешено: max_buffer_size там 2 ГиБ. Ограничена только привязка.
///
/// Отсюда второй вариант: тот же буфер показывается девятью привязками, по одному
/// направлению в каждой, и потолок поднимается в девять раз — до 33.5 млн узлов, чего
/// хватает всей кампании. Платой служит switch в аксессорах, поэтому там, где предела
/// нет, собирается прежний общий вариант без всякого switch.
fn build_shader(split: bool) -> String {
let mut d = String::new();
if split {
for i in 0..math::Q {
d += &format!(
"@group(0) @binding({}) var<storage, read_write> f{i} : array<f32>;
",
POP_F0 as usize + i
);
d += &format!(
"@group(0) @binding({}) var<storage, read_write> q{i} : array<f32>;
",
POP_P0 as usize + i
);
}
let arm = |pfx: &str, body: &dyn Fn(usize) -> String| {
let mut t = String::new();
for i in 0..math::Q - 1 {
t += &format!(" case {i}u: {{ {} }}
", body(i));
}
t += &format!(" default: {{ {} }}
", body(math::Q - 1));
let _ = pfx;
t
};
d += "fn fget(i: u32, nd: u32) -> f32 {
switch i {
";
d += &arm("f", &|i| format!("return f{i}[nd];"));
d += " }
}
";
d += "fn fset(i: u32, nd: u32, v: f32) {
switch i {
";
d += &arm("f", &|i| format!("f{i}[nd] = v;"));
d += " }
}
";
d += "fn pget(i: u32, nd: u32) -> f32 {
switch i {
";
d += &arm("q", &|i| format!("return q{i}[nd];"));
d += " }
}
";
d += "fn pset(i: u32, nd: u32, v: f32) {
switch i {
";
d += &arm("q", &|i| format!("q{i}[nd] = v;"));
d += " }
}
";
} else {
d += "@group(0) @binding(0) var<storage, read_write> f : array<f32>;
";
d += "@group(0) @binding(1) var<storage, read_write> post : array<f32>;
";
d += "fn fget(i: u32, nd: u32) -> f32 { return f[i*P.stride + nd]; }
";
d += "fn fset(i: u32, nd: u32, v: f32) { f[i*P.stride + nd] = v; }
";
d += "fn pget(i: u32, nd: u32) -> f32 { return post[i*P.stride + nd]; }
";
d += "fn pset(i: u32, nd: u32, v: f32) { post[i*P.stride + nd] = v; }
";
}
SHADER.replace("//__POPULATIONS__", &d)
}
fn level_layout(device: &wgpu::Device, split: bool) -> wgpu::BindGroupLayout {
let mut entries = vec![rw(2), ro(3), ro(4), ro(5), rw(6), un(7)];
if split {
for i in 0..math::Q as u32 {
entries.push(rw(POP_F0 + i));
entries.push(rw(POP_P0 + i));
}
} else {
entries.push(rw(0));
entries.push(rw(1));
}
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("level"),
entries: &entries,
})
}
fn dyn_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("dyn"),
entries: &[un(0), un(1), rw(2)],
})
}
fn amr_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("amr"),
entries: &[ro(0), rw(1), ro(2), ro(3), ro(4), ro(5), un(6)],
})
}