From 48cc80bef5602ab92b8c0f7c17559346ebe52295 Mon Sep 17 00:00:00 2001 From: Christian Svensson Date: Fri, 16 Oct 2026 23:52:54 +0200 Subject: [PATCH] solve.fluid: backend "cpu" (the default) or "gpu" The CPU stays the default, so every existing result and fixture is bit-identical; "gpu" steps on the device FIELDCRAFT_GPU picks through the registered backend, and is an error when the build has none. The desktop app and the CLI (hence the MCP server) register fieldcraft-lbm-gpu at start; the summary names what stepped. Hostile values are errors. Part of #529. --- Cargo.lock | 2 ++ apps/fieldcraft-cli/Cargo.toml | 1 + apps/fieldcraft-cli/src/main.rs | 2 ++ apps/fieldcraft/Cargo.toml | 1 + apps/fieldcraft/src/main.rs | 2 ++ crates/engine/src/cmd/forms.rs | 3 +++ crates/engine/src/cmd/solve.rs | 14 ++++++++++++-- crates/lbm/src/solve.rs | 3 +++ 8 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c836ab..cb8d0b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1271,6 +1271,7 @@ dependencies = [ "embed-resource", "fieldcraft-engine", "fieldcraft-io", + "fieldcraft-lbm-gpu", "fieldcraft-ui-egui", "fieldcraft-update", "getrandom 0.4.3", @@ -1290,6 +1291,7 @@ dependencies = [ "fieldcraft-engine", "fieldcraft-gpu", "fieldcraft-io", + "fieldcraft-lbm-gpu", "fieldcraft-mcp", "fieldcraft-render", "fieldcraft-update", diff --git a/apps/fieldcraft-cli/Cargo.toml b/apps/fieldcraft-cli/Cargo.toml index edfc115..652121f 100644 --- a/apps/fieldcraft-cli/Cargo.toml +++ b/apps/fieldcraft-cli/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] fieldcraft-engine = { workspace = true } +fieldcraft-lbm-gpu = { workspace = true } fieldcraft-gpu = { workspace = true } fieldcraft-render = { workspace = true } fieldcraft-io = { workspace = true } diff --git a/apps/fieldcraft-cli/src/main.rs b/apps/fieldcraft-cli/src/main.rs index 3f3f98f..c0b893a 100644 --- a/apps/fieldcraft-cli/src/main.rs +++ b/apps/fieldcraft-cli/src/main.rs @@ -63,6 +63,8 @@ usage: const MAX_SCRIPT_BYTES: u64 = 256 * 1024 * 1024; fn main() -> ExitCode { + // The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"` (scripts and MCP). + fieldcraft_lbm_gpu::register(); let args: Vec = std::env::args().skip(1).collect(); let result = match args.first().map(String::as_str) { Some("commands") if args.get(1).map(String::as_str) == Some("--json") => { diff --git a/apps/fieldcraft/Cargo.toml b/apps/fieldcraft/Cargo.toml index b153a3a..d3f5ae1 100644 --- a/apps/fieldcraft/Cargo.toml +++ b/apps/fieldcraft/Cargo.toml @@ -21,6 +21,7 @@ neg_cmp_op_on_partial_ord = "allow" [dependencies] fieldcraft-engine = { workspace = true } +fieldcraft-lbm-gpu = { workspace = true } fieldcraft-ui-egui = { workspace = true } fieldcraft-io = { workspace = true } fieldcraft-update = { workspace = true } diff --git a/apps/fieldcraft/src/main.rs b/apps/fieldcraft/src/main.rs index 6278252..b422be8 100644 --- a/apps/fieldcraft/src/main.rs +++ b/apps/fieldcraft/src/main.rs @@ -224,6 +224,8 @@ fn parse_args(args: impl IntoIterator, env_port: Option) } fn main() -> eframe::Result { + // The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"`. + fieldcraft_lbm_gpu::register(); let args = match parse_args(std::env::args().skip(1), std::env::var("FIELDCRAFT_CONTROL_PORT").ok()) { Ok(Some(a)) => a, Ok(None) => return Ok(()), diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 190e473..64daef6 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1953,6 +1953,9 @@ pub const SOLVE_EXPLICIT: &[Field] = &[ pub const SOLVE_HISTORY: &[Field] = &[SOLVED.req(), int("every", "Keep one in", 1, 1_000_000_000).help("Keep one history sample in this many.")]; pub const SOLVE_FLUID: &[Field] = &[ STEP, + choice("backend", "Backend", &[("cpu", "CPU"), ("gpu", "GPU")]) + .help("The CPU (double precision, the same result on every machine) or the GPU the build and FIELDCRAFT_GPU pick (single precision).") + .def("\"cpu\""), int("max_cells", "Max cells", 1, 16_000_000).help("Cap on the lattice cells."), int("max_steps", "Max steps", 1, 10_000_000).help("Cap on the lattice time steps."), positive("max_cell_updates", "Max cell updates", D::None).help("Cap on cells × steps."), diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index b1372ed..821905c 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -100,7 +100,7 @@ pub const HISTORY: CommandSpec = CommandSpec { pub const FLUID: CommandSpec = CommandSpec { id: "solve.fluid", label: "Solve Fluid Step", - params: r#"{"step": "Flow", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass and the final force on every wall; the caps may only be lowered; `threads` as for solve.static"#, + params: r#"{"step": "Flow", "backend": "cpu", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass and the final force on every wall; `backend` is `cpu` (the default: double precision, the same result on every machine) or `gpu` (the step on the GPU `FIELDCRAFT_GPU` picks, single precision, within the backend's stated tolerance of the CPU; an error when the build has none); the caps may only be lowered; `threads` as for solve.static"#, form: Some(super::forms::SOLVE_FLUID), kind: CommandKind::Model, requires: &[Requirement::FluidDomain, Requirement::Step], @@ -487,10 +487,15 @@ fn run_fluid(s: &mut Session, p: &Value) -> Result { max_frame_bytes: cap(p, "max_frame_bytes", defaults.max_frame_bytes)?, }; let step = opt::(p, "step")?; + let backend = match opt::(p, "backend")? { + None => fieldcraft_lbm::Choice::Cpu, + Some(b) => fieldcraft_lbm::Choice::parse(&b) + .ok_or_else(|| EngineError::Params(format!("`backend` must be `cpu` or `gpu`, not `{}`", b.chars().take(40).collect::())))?, + }; let (_, domain) = fieldcraft_lbm::fluid_step(&s.model, step.as_deref())?; let obstacles = obstacles(s, domain)?; let model = &s.model; - let r = with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?; + let r = with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid_on(model, step.as_deref(), &obstacles, &limits, backend))?; let summary = fluid_summary(&r); s.store_result(StepResult::Fluid(r)); Ok(summary) @@ -517,6 +522,7 @@ fn fluid_summary(r: &FluidResult) -> Value { let (m0, m1) = (r.frames.first().map_or(0.0, |f| f.mass), last.map_or(0.0, |f| f.mass)); json!({ "step": r.step, + "backend": r.stats.backend, "cells": r.stats.cells, "fluid_cells": r.stats.fluid_cells, "steps": r.stats.steps, @@ -1175,6 +1181,10 @@ pub(crate) mod tests { json!({"step": "Shear", "threads": 0}), json!({"step": "Shear", "threads": 100000}), json!({"step": "Shear", "threads": null}), + json!({"step": "Shear", "backend": "tpu"}), + json!({"step": "Shear", "backend": 7}), + // no device backend is registered in this test binary: an error, not a panic + json!({"step": "Shear", "backend": "gpu"}), ]; for id in ["solve.fluid", "solve.probe", "solve.static", "solve.frequency"] { for p in &hostile { diff --git a/crates/lbm/src/solve.rs b/crates/lbm/src/solve.rs index a1dfd48..f47dbc2 100644 --- a/crates/lbm/src/solve.rs +++ b/crates/lbm/src/solve.rs @@ -55,6 +55,8 @@ pub struct FluidStats { pub mach: f64, /// Fluid cells times steps. pub cell_updates: u64, + /// What stepped the flow: `cpu`, or `gpu: `. + pub backend: String, } /// The result of a fluid step. @@ -209,6 +211,7 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result