Батчинг GPU-чтения, компенсированные суммы и HRR-стенка на обоих бэкендах

БАТЧИНГ. Раньше после каждого шага делался map_async + poll(Wait) ради 48 байт итогов: на
сетке 240×120 счёт упирался в 863 шаг/с при том, что сам счёт занимал 0.27 мс из 1.16 — три
четверти времени машина стояла. Теперь k_stats2 пишет итоги в слот истории step % 128, а хост
читает пачкой. Контракт бэкенда изменён с пошагового step() на advance(&mut out) + flush():
записи дописываются в вектор, синхронизация происходит только там, где дальше нужны
актуальные данные (кадр гифки, живая строка отчёта).

Замер: 240×120 — 6715 шаг/с против 863 (7.8×); пропускная способность 195 MLUPS против прежних
105. Паритет CPU/GPU сохранён: Cd 1.798 против 1.799, ⟨ρ⟩ 1.00090 против 1.00091.

КОМПЕНСИРОВАННОЕ СУММИРОВАНИЕ (Кэхена–Ноймайера) в редукциях статистики и в сумме сил. Именно
там теряется основная точность f32: наивная сумма по 10^5…10^7 узлам съедает ~log2(N) бит.
Аппаратного f64 на целевом железе нет (в WGSL типа f64 не существует вовсе, а локальная Iris Xe
сообщает shaderFloat64 = false), поэтому компенсация — единственный доступный способ.

РАЗВАЛ СЧЁТА теперь ловится по уже посчитанным max|u| и ⟨ρ⟩, а не полным проходом по полю:
на сетке 4096×2048 такой проход тянет с устройства сотни мегабайт, и делать его регулярно
нельзя. Полная проверка осталась одна, в конце.

HRR-СТЕНКА (--wall hrr, теперь умолчание). Условие Града — это ряд Эрмита, оборванный на 2-м
порядке (ρ, u, Π). HRR продолжает его на третий, вычисляя коэффициенты не из популяций (их на
стенке как раз и не хватает), а рекурсивно из уже известных:
  a₃_xxy = 2·u_x·a₂_xy + u_y·a₂_xx,  a₃_xyy = 2·u_y·a₂_xy + u_x·a₂_yy
В D2Q9 a₃_xxx и a₃_yyy решёткой не поддерживаются и отбрасываются. Третий порядок не трогает
ρ, ρu и Π — соответствующие моменты весов обнуляются по симметрии, — что закреплено тестом.

Моментная стенка перенесена на GPU (раньше её там не было вовсе, бэкенд отказывался
запускаться): буфер индекса граничных узлов плюс ядро k_moment_wall. Скорость на момент t
берётся из пост-столкновительного поля — столкновение сохраняет ρ и ρu, поэтому отдельное
хранилище прошлого шага не нужно ни на одном бэкенде. Добавлена проверка лимита
storage-биндингов адаптера: моментной стенке нужно 9 против 8 гарантируемых.

Паритет на HRR: Cd 1.847 (CPU) против 1.848 (GPU). Три модели стенки на одной постановке дают
1.847 (hrr) / 1.845 (grad) / 1.856 (bouzidi).

33 теста, обе сборки чисты.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 03:51:05 +03:00
co-authored by Claude Opus 5
parent e3c2d417f8
commit 039e5f4806
4 changed files with 496 additions and 118 deletions
+45 -16
View File
@@ -197,11 +197,13 @@ struct Cli {
/// Оператор столкновения
#[arg(long, default_value = "kbc", value_parser = ["kbc", "bgk"], help_heading = "Схема")]
collision: String,
/// Модель стенки на теле: bouzidi — интерполированный отскок по линкам; grad — условие
/// Града (Dorschner и др., JFM 801 (2016)), где задаются не популяции, а целевые моменты
/// ρ, u и тензор давлений. Авторы метода предпочитают grad: интерполяционные схемы, по их
/// словам, «ограничены низкими Re, поскольку на границе возникают паразитные скачки».
#[arg(long, default_value = "bouzidi", value_parser = math::WallModel::ALL,
/// Модель стенки на теле. hrr (умолчание) — восстановление недостающих популяций по
/// целевым моментам с рекурсивной регуляризацией до 3-го порядка Эрмита; grad — то же,
/// но с обрывом на тензоре давлений; bouzidi — интерполированный отскок по каждому линку
/// со своей долей пересечения q; staircase — простой отскок, q игнорируется (не для
/// счёта, а как база сравнения). Замерено: по разрешению геометрии bouzidi точнее
/// моментных схем, зато у тех естественная форма для подвижных стенок — см. README.
#[arg(long, default_value = "hrr", value_parser = math::WallModel::ALL,
help_heading = "Схема")]
wall: String,
/// Состав сдвиговой части KBC (табл. I 2D-статьи): n1 — только девиатор {N, Π_xy}
@@ -451,11 +453,23 @@ enum Backend {
}
impl Backend {
fn step(&mut self) -> StepRec {
/// Посчитать шаг. Готовые записи ДОПИСЫВАЮТСЯ в `out` — их может быть ноль (GPU копит
/// итоги в истории и читает пачкой) или сразу много (когда история заполнилась).
fn advance(&mut self, out: &mut Vec<StepRec>) {
match self {
Backend::Cpu(s) => s.step(),
Backend::Cpu(s) => out.push(s.step()),
#[cfg(feature = "gpu")]
Backend::Gpu(s) => s.step(),
Backend::Gpu(s) => s.advance(out),
}
}
/// Дочитать всё посчитанное. Обязательно перед чтением полей: иначе записи отстанут
/// от состояния, которое покажет кадр.
fn flush(&mut self, out: &mut Vec<StepRec>) {
let _ = &out; // процессорный бэкенд отдаёт записи сразу, копить нечего
match self {
Backend::Cpu(_) => {}
#[cfg(feature = "gpu")]
Backend::Gpu(s) => s.flush(out),
}
}
fn sample_field(&mut self, k: FieldKind) -> (Vec<R>, Vec<bool>) {
@@ -598,11 +612,18 @@ fn run(cli: Cli) -> Result<(), String> {
.unwrap_or(0)) as f64;
for t in 0..=spec.steps {
let rec = back.step();
recs.push(rec);
back.advance(&mut recs);
if let Some(w) = writer.as_mut() {
if t % plan.stride == 0 {
let need_frame = writer.is_some() && t % plan.stride == 0;
let need_report = cli.report_every > 0 && !quiet && t % cli.report_every == 0;
// и кадр, и живая строка требуют актуальных данных — синхронизируемся только здесь
if need_frame || need_report {
back.flush(&mut recs);
}
let rec = *recs.last().unwrap_or(&StepRec::default());
if need_frame {
if let Some(w) = writer.as_mut() {
let (field, solid) = back.sample_field(field_kind);
let (cd, _, _) =
math::coefficients(rec.fx, rec.fy, rec.tz, spec.units.u_lat, d_ref);
@@ -610,18 +631,25 @@ fn run(cli: Cli) -> Result<(), String> {
.map_err(|e| format!("запись кадра: {e}"))?;
}
}
if cli.report_every > 0 && !quiet && t % cli.report_every == 0 {
if need_report {
live_line(&spec, &rec, d_ref, t_start.elapsed().as_secs_f64(), nodes_per_step, full);
}
// развал счёта: проверяем редко, проверка стоит полного прохода по полю
if t % 500 == 0 && !back.is_finite() {
// Развал счёта ловится по УЖЕ ПОСЧИТАННЫМ величинам, а не полным проходом по полю:
// на сетке 4096×2048 такой проход тянет с устройства сотни мегабайт, и делать его
// регулярно нельзя. Полная проверка — один раз, для подтверждения.
if !rec.max_u.is_finite() || !rec.rho_mean.is_finite() {
back.flush(&mut recs);
eprintln!("\nСЧЁТ РАЗВАЛИЛСЯ на шаге {t}: в поле появились NaN/inf.");
blew_up = true;
break;
}
}
back.flush(&mut recs);
if !blew_up && !back.is_finite() {
eprintln!("\nСЧЁТ РАЗВАЛИЛСЯ: итоговое поле содержит NaN/inf.");
blew_up = true;
}
let wall = t_start.elapsed().as_secs_f64();
if let Some(w) = writer {
@@ -729,6 +757,7 @@ fn print_header(spec: &Spec, cli: &Cli, plan: &gif::GifPlan, field: FieldKind) {
match spec.wall {
math::WallModel::Bouzidi => "Bouzidi",
math::WallModel::Grad => "Град (моменты)",
math::WallModel::Hrr => "HRR (моменты, 3-й пор.)",
math::WallModel::Staircase => "простой отскок",
});
println!(" выход по u_y {:>12} губка {} столбцов ×{:.0}",