Deterministic CPU multithreading: supernodal Cholesky, parallel elements, LBM blocks (#50)

* parallel: deterministic thread pool helpers for the solvers (fixed partitions, ordered results)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* solver: supernodal left-looking Cholesky, multithreaded with a fixed operation order

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* solver: parallel element matrices, stresses, Lanczos block solves and products (sums in the sequential order)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* explicit: element updates in parallel, nodal forces added in element order (bit-identical to the sequential loop)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* lbm: collision and streaming by fixed cell blocks in parallel, wall forces in link order (bit-identical)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: threads setting for the solve commands (session default from MODELCRAFT_THREADS or the cores); bit-identity test on 1, 2 and 7 threads

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: rayon threads in ADR 0001, the parallel crate, supernodal Cholesky

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 22:26:44 -04:00
1 parent 5afd1addb9
commit 4ce5513210
25 files changed
+1710 -342

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ modelcraft-solver = { workspace = true }
modelcraft-explicit = { workspace = true }
modelcraft-fem = { workspace = true }
modelcraft-lbm = { workspace = true }
modelcraft-parallel = { workspace = true }
modelcraft-render = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+101 -10
View File
@@ -5,6 +5,7 @@ use modelcraft_explicit::{ExplicitResult, solve_explicit};
use modelcraft_lbm::{FluidResult, Obstacle};
use modelcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
use modelcraft_model::FluidDomain;
use modelcraft_parallel as parallel;
use modelcraft_solver::{
FrequencyOptions, Limits, Location, MassKind, StaticResult, StepResult as ImplicitResult, solve_frequency, solve_heat, solve_static,
};
@@ -24,7 +25,7 @@ pub const RUN: CommandSpec = CommandSpec {
pub const STATIC: CommandSpec = CommandSpec {
id: "solve.static",
label: "Solve Static Step",
params: r#"{"step": "Load", "max_dofs": 6000000, "max_factor_entries": 400000000} — solves a linear static or coupled temperature–displacement step (the only one when `step` is left out) and keeps the result in the session; the caps may only be lowered"#,
params: r#"{"step": "Load", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — solves a linear static or coupled temperature–displacement step (the only one when `step` is left out) and keeps the result in the session; the caps may only be lowered; `threads` (1 to 256, by default the session's: `MODELCRAFT_THREADS` or the cores) changes only the time, never the result"#,
kind: CommandKind::Model,
run: run_static,
};
@@ -32,7 +33,7 @@ pub const STATIC: CommandSpec = CommandSpec {
pub const HEAT: CommandSpec = CommandSpec {
id: "solve.heat",
label: "Solve Heat Transfer Step",
params: r#"{"step": "Heat", "max_dofs": 6000000, "max_factor_entries": 400000000} — steady or transient heat conduction of a heat transfer step (the only one when `step` is left out): temperatures, heat flux and the heat flow at prescribed temperatures, kept in the session; the caps may only be lowered"#,
params: r#"{"step": "Heat", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — steady or transient heat conduction of a heat transfer step (the only one when `step` is left out): temperatures, heat flux and the heat flow at prescribed temperatures, kept in the session; the caps may only be lowered; `threads` as for solve.static"#,
kind: CommandKind::Model,
run: run_heat,
};
@@ -40,7 +41,7 @@ pub const HEAT: CommandSpec = CommandSpec {
pub const FREQUENCY: CommandSpec = CommandSpec {
id: "solve.frequency",
label: "Solve Frequency Step",
params: r#"{"step": "Modes", "mass": "consistent|lumped", "shift": 0.0, "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000} — natural frequencies and mode shapes of a frequency step (its modes and range); `max_iterations` caps the Lanczos vectors; the result stays in the session"#,
params: r#"{"step": "Modes", "mass": "consistent|lumped", "shift": 0.0, "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — natural frequencies and mode shapes of a frequency step (its modes and range); `max_iterations` caps the Lanczos vectors; the result stays in the session; `threads` as for solve.static"#,
kind: CommandKind::Model,
run: run_frequency,
};
@@ -48,7 +49,7 @@ pub const FREQUENCY: CommandSpec = CommandSpec {
pub const EXPLICIT: CommandSpec = CommandSpec {
id: "solve.explicit",
label: "Solve Explicit Dynamic Step",
params: r#"{"step": "Impact", "max_dofs": 6000000, "max_increments": 1000000000, "max_output_values": 50000000} — transient response of an explicit dynamic step (the only one when `step` is left out) by central differences; keeps its histories and field frames in the session; the caps may only be lowered (the step's own increment cap applies too)"#,
params: r#"{"step": "Impact", "max_dofs": 6000000, "max_increments": 1000000000, "max_output_values": 50000000, "threads": 8} — transient response of an explicit dynamic step (the only one when `step` is left out) by central differences; keeps its histories and field frames in the session; the caps may only be lowered (the step's own increment cap applies too); `threads` as for solve.static"#,
kind: CommandKind::Model,
run: run_explicit,
};
@@ -64,7 +65,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} — 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"#,
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"#,
kind: CommandKind::Model,
run: run_fluid,
};
@@ -99,6 +100,18 @@ fn cap<T: Copy + TryInto<u64> + TryFrom<u64> + std::fmt::Display>(p: &Value, key
}
}
/// The `threads` parameter: 1 to [`parallel::MAX_THREADS`], by default the session's.
fn threads(s: &Session, p: &Value) -> Result<usize> {
match p.get("threads") {
None => Ok(s.limits.threads.clamp(1, parallel::MAX_THREADS)),
Some(v) => v
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| (1..=parallel::MAX_THREADS).contains(n))
.ok_or_else(|| EngineError::Params(format!("`threads` must be a whole number from 1 to {}", parallel::MAX_THREADS))),
}
}
fn limits(p: &Value) -> Result<Limits> {
Ok(Limits {
max_dofs: cap(p, "max_dofs", Limits::MAX_DOFS)?,
@@ -155,7 +168,8 @@ fn smallest(values: impl Iterator<Item = (u32, f64)>) -> Value {
fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
let limits = limits(p)?;
let step = opt::<String>(p, "step")?;
let r = solve_static(&s.model, step.as_deref(), &limits)?;
let model = &s.model;
let r = parallel::with_threads(threads(s, p)?, || solve_static(model, step.as_deref(), &limits))?;
let summary = json!({
"step": r.step,
@@ -182,7 +196,8 @@ fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
fn run_heat(s: &mut Session, p: &Value) -> Result<Value> {
let limits = limits(p)?;
let step = opt::<String>(p, "step")?;
let r = solve_heat(&s.model, step.as_deref(), &limits)?;
let model = &s.model;
let r = parallel::with_threads(threads(s, p)?, || solve_heat(model, step.as_deref(), &limits))?;
let t = r.temperature();
let summary = json!({
"step": r.step,
@@ -206,7 +221,8 @@ fn run_frequency(s: &mut Session, p: &Value) -> Result<Value> {
let step = opt::<String>(p, "step")?;
let mass = opt::<MassKind>(p, "mass")?.unwrap_or_default();
let shift = opt::<f64>(p, "shift")?;
let r = solve_frequency(&s.model, step.as_deref(), &FrequencyOptions { mass, shift }, &limits)?;
let model = &s.model;
let r = parallel::with_threads(threads(s, p)?, || solve_frequency(model, step.as_deref(), &FrequencyOptions { mass, shift }, &limits))?;
let summary = json!({
"step": r.step,
"dofs": r.stats.dofs,
@@ -233,7 +249,8 @@ fn run_explicit(s: &mut Session, p: &Value) -> Result<Value> {
max_output_values: cap(p, "max_output_values", ExplicitLimits::MAX_OUTPUT_VALUES)?,
};
let step = opt::<String>(p, "step")?;
let r = solve_explicit(&s.model, step.as_deref(), &limits)?;
let model = &s.model;
let r = parallel::with_threads(threads(s, p)?, || solve_explicit(model, step.as_deref(), &limits))?;
let summary = explicit_summary(&r);
s.store_result(StepResult::Explicit(r));
Ok(summary)
@@ -333,7 +350,8 @@ fn run_fluid(s: &mut Session, p: &Value) -> Result<Value> {
let step = opt::<String>(p, "step")?;
let (_, domain) = modelcraft_lbm::fluid_step(&s.model, step.as_deref())?;
let obstacles = obstacles(s, domain)?;
let r = modelcraft_lbm::solve_fluid(&s.model, step.as_deref(), &obstacles, &limits)?;
let model = &s.model;
let r = parallel::with_threads(threads(s, p)?, || modelcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?;
let summary = fluid_summary(&r);
s.store_result(StepResult::Fluid(r));
Ok(summary)
@@ -756,6 +774,13 @@ pub(crate) mod tests {
json!({"step": "Modes", "nodes": [1], "mode": 1e300}),
json!({"step": "Bend", "nodes": [0, 99999999, 4294967296u64]}),
json!({"step": "Bend", "nodes": vec![1; 20_000]}),
json!({"step": "Bend", "threads": 0}),
json!({"step": "Bend", "threads": -3}),
json!({"step": "Bend", "threads": 257}),
json!({"step": "Bend", "threads": 1e300}),
json!({"step": "Bend", "threads": "all"}),
json!({"step": "Modes", "threads": 18446744073709551615u64}),
json!({"step": "Modes", "threads": 2.5}),
];
for id in ["solve.static", "solve.frequency", "solve.probe"] {
for p in &hostile {
@@ -896,6 +921,9 @@ pub(crate) mod tests {
json!({"step": "Shear", "forces": vec!["y_min"; 1000]}),
json!({"step": "Shear", "forces": "y_min"}),
json!({"step": "Shear", "nodes": [1]}),
json!({"step": "Shear", "threads": 0}),
json!({"step": "Shear", "threads": 100000}),
json!({"step": "Shear", "threads": null}),
];
for id in ["solve.fluid", "solve.probe", "solve.static", "solve.frequency"] {
for p in &hostile {
@@ -972,6 +1000,69 @@ pub(crate) mod tests {
s.run_script(&script).unwrap();
}
/// The stored result of a step, hashed bit for bit (Debug prints every float exactly).
fn result_hash(s: &Session, step: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::hash::DefaultHasher::new();
format!("{:?}", s.result(step).unwrap()).hash(&mut h);
h.finish()
}
/// Implicit (static, frequency), explicit and lattice Boltzmann solves large enough for every
/// parallel path (factorization tasks and tiles, element rounds, cell blocks) give the same
/// bits on 1, 2 and 7 threads.
#[test]
fn results_do_not_depend_on_the_thread_count() {
let solve_all = |threads: usize| -> Vec<u64> {
let mut s = Session::default();
explicit_cantilever(&mut s, 10.0, [30, 6]);
let script = json!([
{"command": "model.load.create", "params": {"name": "push", "type": "force", "target": {"set": "x30"}, "vector": [0, 0.3, -1]}},
{"command": "model.step.create", "params": {"name": "Bend", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}},
]);
s.run_script(&script).unwrap();
s.execute("solve.static", &json!({"step": "Bend", "threads": threads})).unwrap();
s.execute("solve.frequency", &json!({"step": "Modes", "threads": threads})).unwrap();
let mut hashes = vec![result_hash(&s, "Bend"), result_hash(&s, "Modes")];
let step = json!({
"type": "explicit_dynamic", "end_time": 0.15, "output_interval": 0.05,
"initial_velocities": [{"set": "x30", "velocity": [0, 0, -2]}], "hourglass": {"type": "stiffness"}
});
let script = json!([
{"command": "model.section.edit", "params": {"name": "beam", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {"integration": "reduced"}}},
{"command": "model.step.create", "params": {"name": "Hit", "procedure": step, "boundary_conditions": ["clamp"]}},
]);
s.run_script(&script).unwrap();
s.execute("solve.explicit", &json!({"step": "Hit", "threads": threads})).unwrap();
hashes.push(result_hash(&s, "Hit"));
let mut f = Session::default();
let periodic = json!({"type": "periodic"});
let script = json!([
{"command": "model.material.create", "params": {"name": "Oil", "density": 1.0, "fluid": {"type": "newtonian", "viscosity": 0.1}}},
{"command": "model.fluid_domain.create", "params": {"name": "Gap", "material": "Oil", "min": [0, 0, 0], "max": [5, 1, 2], "spacing": 0.125,
"faces": {"z_min": periodic, "z_max": periodic}}},
{"command": "model.fluid_domain.boundary", "params": {"domain": "Gap", "at": "y_max", "condition": {"type": "moving_wall", "velocity": [0.1, 0, 0.02]}}},
{"command": "model.step.create", "params": {"name": "Shear", "procedure": {"type": "fluid", "domain": "Gap", "end_time": 2, "frames": 2}}},
]);
f.run_script(&script).unwrap();
f.execute("solve.fluid", &json!({"step": "Shear", "threads": threads})).unwrap();
hashes.push(result_hash(&f, "Shear"));
hashes
};
let one = solve_all(1);
for threads in [2, 7] {
assert_eq!(solve_all(threads), one, "{threads} threads");
}
let mut s = Session::default();
cantilever(&mut s);
for bad in [json!(0), json!(257), json!(-1), json!(1.5), json!("8")] {
let e = s.execute("solve.static", &json!({"threads": bad})).unwrap_err().to_string();
assert!(e.contains("`threads` must be a whole number from 1 to 256"), "{e}");
}
s.limits.threads = 3;
s.execute("solve.static", &json!({})).unwrap();
}
/// Upward zero crossings of a sampled signal, linearly interpolated.
fn crossings(time: &[f64], x: &[f64]) -> Vec<f64> {
let mut out = Vec::new();
+6 -2
View File
@@ -135,12 +135,16 @@ pub struct Limits {
pub max_steiner: usize,
/// `mesh.tet.generate`'s `max_tets`.
pub max_tets: usize,
/// Threads the solvers use (the `solve.*` commands' `threads`): by default
/// `MODELCRAFT_THREADS` or the available cores, 1 to [`modelcraft_parallel::MAX_THREADS`].
/// Results do not depend on it.
pub threads: usize,
}
impl Default for Limits {
fn default() -> Self {
let cdt = modelcraft_mesh::cdt::CdtOptions::default();
Limits { max_steiner: cdt.max_steiner, max_tets: cdt.max_tets }
Limits { max_steiner: cdt.max_steiner, max_tets: cdt.max_tets, threads: modelcraft_parallel::default_threads() }
}
}
@@ -469,7 +473,7 @@ mod tests {
// Small work caps, so input that would mesh up to the production caps (needle triangles
// need Steiner points all along them) is refused at once. The caps themselves are what
// `tet_generate_honours_the_session_caps` checks.
let capped = || Session { limits: Limits { max_steiner: 64, max_tets: 100_000 }, ..Session::default() };
let capped = || Session { limits: Limits { max_steiner: 64, max_tets: 100_000, ..Limits::default() }, ..Session::default() };
for spec in commands() {
for p in &hostile {
let mut s = capped();
+1
View File
@@ -11,5 +11,6 @@ workspace = true
[dependencies]
modelcraft-model = { workspace = true }
modelcraft-fem = { workspace = true }
modelcraft-parallel = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
+265 -100
View File
@@ -372,123 +372,288 @@ pub fn hex_scales(
HexScales { size, coefficient, omega2 }
}
/// Elements per parallel task.
const BLOCK: usize = 128;
/// Tasks whose element forces are computed before they are added (bounds the memory of the
/// element forces held at once).
const ROUND: usize = 256;
/// What one element gives an evaluation.
enum Outcome<const N: usize> {
/// Eroded earlier: nothing.
Skipped,
/// Eroded now.
Eroded(Failure),
Forces(Forces<N>),
}
/// An element's nodal forces by origin and its critical increment.
struct Forces<const N: usize> {
hourglass: Option<[[f64; 3]; N]>,
stress: [[f64; 3]; N],
viscous: Option<[[f64; 3]; N]>,
critical: f64,
}
/// A block of consecutive elements of one kind (positions `items` in their list) with the
/// slice of element states that holds theirs, from state index `base` on.
struct Piece<'s, T> {
items: std::ops::Range<usize>,
base: usize,
states: &'s mut [ElementState],
out: Vec<Result<T>>,
}
/// Cut the element states into one slice per block of [`BLOCK`] elements of a kind whose state
/// indices (`index(i)` for item i) increase, as [`System`] builds them.
fn pieces<'s, T>(states: &'s mut [ElementState], count: usize, index: &impl Fn(usize) -> usize) -> Result<Vec<Piece<'s, T>>> {
let ranges: Vec<std::ops::Range<usize>> = (0..count.div_ceil(BLOCK)).map(|k| k * BLOCK..((k + 1) * BLOCK).min(count)).collect();
let mut out = Vec::with_capacity(ranges.len());
let mut rest = states;
let mut at = 0;
for (k, items) in ranges.iter().enumerate() {
let base = index(items.start);
let end = ranges.get(k + 1).map_or(at + rest.len(), |r| index(r.start));
if base < at || end < base || end - at > rest.len() {
return Err(Error::Internal("element states out of order".into()));
}
let (_, tail) = std::mem::take(&mut rest).split_at_mut(base - at);
let (mine, tail) = tail.split_at_mut(end - base);
out.push(Piece { items: items.clone(), base, states: mine, out: Vec::new() });
rest = tail;
at = end;
}
Ok(out)
}
/// `f(i, state)` for every element i of a kind, in parallel by blocks and a round of blocks at a
/// time; the outcomes go to `apply` in element order, so whatever it sums is summed as a
/// sequential loop would. The first error in element order stops the evaluation.
fn run<T: Send>(
states: &mut [ElementState],
count: usize,
index: impl Fn(usize) -> usize + Sync,
f: impl Fn(usize, &mut ElementState) -> Result<T> + Sync + Send,
mut apply: impl FnMut(usize, T) -> Result<()>,
) -> Result<()> {
let mut all = pieces::<T>(states, count, &index)?;
for round in all.chunks_mut(ROUND) {
modelcraft_parallel::for_each_mut(round, |_, piece| {
let Piece { items, base, states, out } = piece;
*out = items
.clone()
.map(|i| match states.get_mut(index(i).wrapping_sub(*base)) {
Some(es) => f(i, es),
None => Err(Error::Internal("element state".into())),
})
.collect();
});
for piece in round.iter_mut() {
for (i, outcome) in piece.items.clone().zip(std::mem::take(&mut piece.out)) {
apply(i, outcome?)?;
}
}
}
Ok(())
}
/// Add an element's forces into the workspace and return its critical increment.
fn add<const N: usize>(ws: &mut Workspace, nodes: &[usize; N], f: &Forces<N>) -> f64 {
if let Some(h) = &f.hourglass {
scatter(&mut ws.hourglass_force, nodes, h);
}
scatter(&mut ws.stress_force, nodes, &f.stress);
if let Some(q) = &f.viscous {
scatter(&mut ws.viscous_force, nodes, q);
}
f.critical
}
/// One hexahedron's update and forces.
fn hexahedron(sys: &System, h: &crate::system::Hex, m: &Motion, es: &mut ElementState) -> Result<Outcome<8>> {
let Some(mat) = sys.materials.get(h.material) else { return Ok(Outcome::Skipped) };
if es.failed.is_some() {
return Ok(Outcome::Skipped);
}
let hourglass = modelcraft_model::Hourglass { kind: sys.hourglass, coefficient: sys.hourglass_coefficient };
let e = Nodal::gather(sys, &h.nodes, m);
let x_now = e.positions(0.0);
let (mid, now) = if sys.nonlinear {
let at = |x: &[[f64; 3]; 8]| Hex8::hex8_deformed(x).map_err(|_| inverted(h.label, m.time));
(at(&e.positions(0.5))?, at(&x_now)?)
} else {
(h.kinematics, h.kinematics)
};
let g = mid.gradient(&e.du);
let q = rotation_of(sys, mat, es, &g, h.label, m.time)?;
let (d, _) = rotation::split(&g);
let d = mechanical(d, h.thermal_rate * m.dt);
let (rho, c) = current(mat, h.kinematics.volume, now.volume);
update(mat, es, &q, &d, d[0] + d[1] + d[2], m.dt, rho, mid.volume);
if let Some(failure) = es.failed {
return Ok(Outcome::Eroded(failure));
}
// Hourglass forces with the current shape vectors and size (fixed without nonlinear
// geometry).
let (gamma, scales) = if sys.nonlinear {
let gamma = now.hourglass_vectors(&x_now);
let scales = hex_scales(&now, &gamma, &h.mass, mat, rho, hourglass);
(gamma, scales)
} else {
(h.gamma, h.rest)
};
let mut f = [[0.0; 3]; 8];
let modes = if sys.hourglass == HourglassKind::Viscous { &e.v } else { &e.du };
for (k, g) in gamma.iter().enumerate() {
let rate: [f64; 3] = [0, 1, 2].map(|i| modes.iter().zip(g).map(|(x, gk)| x[i] * gk).sum());
// Viscous: Q = C q̇; stiffness: Q accumulates k Δq.
let generalized = match (sys.hourglass, es.hourglass.get_mut(k)) {
(HourglassKind::Stiffness, Some(qa)) => {
for i in 0..3 {
qa[i] += scales.coefficient * rate[i];
}
*qa
}
_ => rate.map(|r| scales.coefficient * r),
};
for (fk, gk) in f.iter_mut().zip(g) {
for i in 0..3 {
fk[i] += generalized[i] * gk;
}
}
}
let element = Element { now: &now, mid: &mid, density: rho, wave_speed: c, length: 2.0 * c / scales.omega2.sqrt() };
let mut forces = element.finish(&es.point.stress, &e.v, m.bulk);
forces.hourglass = Some(f);
Ok(Outcome::Forces(forces))
}
/// A tetrahedron's first pass: its increment (none when it has eroded).
fn tetrahedron_increment(sys: &System, t: &crate::system::Tet, m: &Motion, state: &State) -> Result<Option<TetIncrement>> {
if state.elements.get(t.index).is_none_or(|e| e.failed.is_some()) {
return Ok(None);
}
let e = Nodal::gather(sys, &t.nodes, m);
let (mid, now) = if sys.nonlinear {
let at = |x: &[[f64; 3]; 4]| Tet4::tet4(x).map_err(|_| inverted(t.label, m.time));
(at(&e.positions(0.5))?, at(&e.positions(0.0))?)
} else {
(t.kinematics, t.kinematics)
};
let g = mid.gradient(&e.du);
Ok(Some(TetIncrement { g, mid, now, v: e.v }))
}
/// A tetrahedron's second pass: its update with the mean of its nodes' volumetric increments.
fn tetrahedron(
sys: &System,
t: &crate::system::Tet,
inc: Option<TetIncrement>,
theta: &[f64],
m: &Motion,
es: &mut ElementState,
) -> Result<Outcome<4>> {
let (Some(mat), Some(inc)) = (sys.materials.get(t.material), inc) else { return Ok(Outcome::Skipped) };
let averaged = t.nodes.iter().map(|n| theta.get(*n).copied().unwrap_or(0.0)).sum::<f64>() / 4.0;
let q = rotation_of(sys, mat, es, &inc.g, t.label, m.time)?;
let (d, _) = rotation::split(&inc.g);
let thermal = t.thermal_rate * m.dt;
let d = mechanical(d, thermal);
let (rho, c) = current(mat, t.kinematics.volume, inc.now.volume);
update(mat, es, &q, &d, averaged - 3.0 * thermal, m.dt, rho, inc.mid.volume);
if let Some(failure) = es.failed {
return Ok(Outcome::Eroded(failure));
}
let omega2 = if sys.nonlinear { frequency_bound(&inc.now, inc.now.gradient_norm(), mat.modulus, &t.mass) } else { t.omega2 };
let element = Element { now: &inc.now, mid: &inc.mid, density: rho, wave_speed: c, length: 2.0 * c / omega2.sqrt() };
Ok(Outcome::Forces(element.finish(&es.point.stress, &inc.v, m.bulk)))
}
/// Evaluate the internal forces at the motion `m` into `ws`, updating the element states, and
/// return the smallest critical increment of the elements still in the analysis.
///
/// The elements are updated in parallel (each owns its state); their nodal forces are added in
/// element order, hexahedra first, so every sum is the one of a sequential loop and the result
/// does not depend on the number of threads.
pub fn internal_forces(sys: &System, m: &Motion, state: &mut State, ws: &mut Workspace) -> Result<f64> {
for f in [&mut ws.stress_force, &mut ws.hourglass_force, &mut ws.viscous_force] {
f.fill(0.0);
}
state.eroded.clear();
let mut critical = f64::INFINITY;
let hourglass = modelcraft_model::Hourglass { kind: sys.hourglass, coefficient: sys.hourglass_coefficient };
for h in &sys.hexes {
let (Some(mat), Some(es)) = (sys.materials.get(h.material), state.elements.get_mut(h.index)) else { continue };
if es.failed.is_some() {
continue;
}
let e = Nodal::gather(sys, &h.nodes, m);
let x_now = e.positions(0.0);
let (mid, now) = if sys.nonlinear {
let at = |x: &[[f64; 3]; 8]| Hex8::hex8_deformed(x).map_err(|_| inverted(h.label, m.time));
(at(&e.positions(0.5))?, at(&x_now)?)
} else {
(h.kinematics, h.kinematics)
};
let g = mid.gradient(&e.du);
let q = rotation_of(sys, mat, es, &g, h.label, m.time)?;
let (d, _) = rotation::split(&g);
let d = mechanical(d, h.thermal_rate * m.dt);
let (rho, c) = current(mat, h.kinematics.volume, now.volume);
update(mat, es, &q, &d, d[0] + d[1] + d[2], m.dt, rho, mid.volume);
if let Some(failure) = es.failed {
state.eroded.push((h.index, failure));
continue;
}
// Hourglass forces with the current shape vectors and size (fixed without nonlinear
// geometry).
let (gamma, scales) = if sys.nonlinear {
let gamma = now.hourglass_vectors(&x_now);
let scales = hex_scales(&now, &gamma, &h.mass, mat, rho, hourglass);
(gamma, scales)
} else {
(h.gamma, h.rest)
};
let mut f = [[0.0; 3]; 8];
let modes = if sys.hourglass == HourglassKind::Viscous { &e.v } else { &e.du };
for (k, g) in gamma.iter().enumerate() {
let rate: [f64; 3] = [0, 1, 2].map(|i| modes.iter().zip(g).map(|(x, gk)| x[i] * gk).sum());
// Viscous: Q = C q̇; stiffness: Q accumulates k Δq.
let generalized = match (sys.hourglass, es.hourglass.get_mut(k)) {
(HourglassKind::Stiffness, Some(qa)) => {
for i in 0..3 {
qa[i] += scales.coefficient * rate[i];
}
*qa
}
_ => rate.map(|r| scales.coefficient * r),
};
for (fk, gk) in f.iter_mut().zip(g) {
for i in 0..3 {
fk[i] += generalized[i] * gk;
}
let State { elements, eroded } = state;
let hexes = &sys.hexes;
run(
elements,
hexes.len(),
|i| hexes.get(i).map_or(usize::MAX, |h| h.index),
|i, es| match hexes.get(i) {
Some(h) => hexahedron(sys, h, m, es),
None => Ok(Outcome::Skipped),
},
|i, outcome| {
let Some(h) = hexes.get(i) else { return Ok(()) };
match outcome {
Outcome::Skipped => {}
Outcome::Eroded(failure) => eroded.push((h.index, failure)),
Outcome::Forces(f) => critical = critical.min(add(ws, &h.nodes, &f)),
}
}
scatter(&mut ws.hourglass_force, &h.nodes, &f);
let element = Element { now: &now, mid: &mid, nodes: &h.nodes, density: rho, wave_speed: c, length: 2.0 * c / scales.omega2.sqrt() };
critical = critical.min(element.finish(ws, &es.point.stress, &e.v, m.bulk));
}
Ok(())
},
)?;
if !sys.tets.is_empty() {
let tets = &sys.tets;
// Pass 1: each tetrahedron's increment, and each node's mean volumetric increment.
let increments = {
let st: &State = state;
modelcraft_parallel::map_grained(tets.len(), BLOCK, |k| match tets.get(k) {
Some(t) => tetrahedron_increment(sys, t, m, st),
None => Ok(None),
})
};
ws.theta.fill(0.0);
ws.tet_volume.fill(0.0);
for (k, t) in sys.tets.iter().enumerate() {
let Some(slot) = ws.tet_increment.get_mut(k) else { continue };
*slot = None;
if state.elements.get(t.index).is_none_or(|e| e.failed.is_some()) {
continue;
}
let e = Nodal::gather(sys, &t.nodes, m);
let (mid, now) = if sys.nonlinear {
let at = |x: &[[f64; 3]; 4]| Tet4::tet4(x).map_err(|_| inverted(t.label, m.time));
(at(&e.positions(0.5))?, at(&e.positions(0.0))?)
} else {
(t.kinematics, t.kinematics)
};
let g = mid.gradient(&e.du);
for ((t, slot), inc) in tets.iter().zip(ws.tet_increment.iter_mut()).zip(increments) {
*slot = inc?;
let Some(inc) = slot else { continue };
for node in t.nodes {
if let (Some(x), Some(w)) = (ws.theta.get_mut(node), ws.tet_volume.get_mut(node)) {
*x += mid.volume * (g[0][0] + g[1][1] + g[2][2]);
*w += mid.volume;
*x += inc.mid.volume * (inc.g[0][0] + inc.g[1][1] + inc.g[2][2]);
*w += inc.mid.volume;
}
}
*slot = Some(TetIncrement { g, mid, now, v: e.v });
}
for (x, w) in ws.theta.iter_mut().zip(&ws.tet_volume) {
if *w > 0.0 {
*x /= w;
}
}
// Pass 2: each tetrahedron's update with the mean of its nodes' volumetric increments.
for (k, t) in sys.tets.iter().enumerate() {
let (Some(mat), Some(es), Some(Some(inc))) =
(sys.materials.get(t.material), state.elements.get_mut(t.index), ws.tet_increment.get(k).copied())
else {
continue;
};
let averaged = t.nodes.iter().map(|n| ws.theta.get(*n).copied().unwrap_or(0.0)).sum::<f64>() / 4.0;
let q = rotation_of(sys, mat, es, &inc.g, t.label, m.time)?;
let (d, _) = rotation::split(&inc.g);
let thermal = t.thermal_rate * m.dt;
let d = mechanical(d, thermal);
let (rho, c) = current(mat, t.kinematics.volume, inc.now.volume);
update(mat, es, &q, &d, averaged - 3.0 * thermal, m.dt, rho, inc.mid.volume);
if let Some(failure) = es.failed {
state.eroded.push((t.index, failure));
continue;
// Pass 2: each tetrahedron's update.
let State { elements, eroded } = state;
let (theta, increments) = (&ws.theta, &ws.tet_increment);
let mut forces: Vec<(usize, Forces<4>)> = Vec::new();
run(
elements,
tets.len(),
|k| tets.get(k).map_or(usize::MAX, |t| t.index),
|k, es| match tets.get(k) {
Some(t) => tetrahedron(sys, t, increments.get(k).copied().flatten(), theta, m, es),
None => Ok(Outcome::Skipped),
},
|k, outcome| {
match (tets.get(k), outcome) {
(Some(t), Outcome::Eroded(failure)) => eroded.push((t.index, failure)),
(Some(_), Outcome::Forces(f)) => forces.push((k, f)),
_ => {}
}
Ok(())
},
)?;
for (k, f) in &forces {
if let Some(t) = tets.get(*k) {
critical = critical.min(add(ws, &t.nodes, f));
}
let omega2 = if sys.nonlinear { frequency_bound(&inc.now, inc.now.gradient_norm(), mat.modulus, &t.mass) } else { t.omega2 };
let element = Element { now: &inc.now, mid: &inc.mid, nodes: &t.nodes, density: rho, wave_speed: c, length: 2.0 * c / omega2.sqrt() };
critical = critical.min(element.finish(ws, &es.point.stress, &inc.v, m.bulk));
}
}
for r in &sys.rods {
@@ -524,21 +689,21 @@ struct TetIncrement {
struct Element<'a, const N: usize> {
now: &'a UniformStrain<N>,
mid: &'a UniformStrain<N>,
nodes: &'a [usize; N],
density: f64,
wave_speed: f64,
length: f64,
}
impl<const N: usize> Element<'_, N> {
/// Add the stress and bulk viscosity forces and return the critical increment.
fn finish(&self, ws: &mut Workspace, sigma: &[f64; 6], v: &[[f64; 3]; N], bulk: &BulkViscosity) -> f64 {
/// The stress and bulk viscosity forces and the critical increment.
fn finish(&self, sigma: &[f64; 6], v: &[[f64; 3]; N], bulk: &BulkViscosity) -> Forces<N> {
let rate = self.mid.divergence(v);
let q = bulk_pressure(rate, self.density, self.length, self.wave_speed, bulk);
scatter(&mut ws.stress_force, self.nodes, &self.now.forces(sigma));
if q != 0.0 {
scatter(&mut ws.viscous_force, self.nodes, &self.now.pressure_forces(q));
Forces {
hourglass: None,
stress: self.now.forces(sigma),
viscous: (q != 0.0).then(|| self.now.pressure_forces(q)),
critical: critical_increment(rate, self.length, self.wave_speed, bulk),
}
critical_increment(rate, self.length, self.wave_speed, bulk)
}
}
+4 -3
View File
@@ -24,9 +24,10 @@
//! docs/solvers/literature.md, Parts F and J). Without it the gradients stay those of the
//! undeformed mesh (small displacements).
//!
//! Everything is sequential, with elements in label order and no parallel reductions, so the
//! same model gives the same numbers bit for bit; sizes, increments and output are capped by
//! [`Limits`].
//! Elements are updated on threads ([`modelcraft_parallel`]), each owning its state, and their
//! nodal forces are added in label order as a sequential loop adds them, with no parallel
//! reductions, so the same model gives the same numbers bit for bit on any number of threads;
//! sizes, increments and output are capped by [`Limits`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
// Small fixed-size vector kernels read as the textbook formulas with index loops.
+1
View File
@@ -11,4 +11,5 @@ workspace = true
[dependencies]
modelcraft-model = { workspace = true }
modelcraft-mesh = { workspace = true }
modelcraft-parallel = { workspace = true }
thiserror = { workspace = true }
+123 -77
View File
@@ -32,11 +32,14 @@
//! pulls: every population of a cell comes from one source (a neighbour, a periodic image, the
//! cell itself through a wall, or an open boundary). Cells whose 18 neighbours are all fluid
//! cells inside the box stream by one shifted copy per direction; the others follow per-link
//! rules. Every loop runs in a fixed order with no parallel reductions, so the result is the
//! same bit for bit on every platform.
//! rules. Collision and streaming run by fixed blocks of cells in parallel, each cell writing
//! only its own populations; the wall forces are summed in link order. There are no parallel
//! reductions, so the result is the same bit for bit on any number of threads and platform.
use modelcraft_model::{BoxFace, FaceCondition, Profile};
use modelcraft_parallel as parallel;
use crate::lattice::{C, Q, W, c, equilibrium, moving_wall, opposite};
use crate::setup::{MAX_LATTICE_SPEED, Obstacle, Setup};
use crate::voxel::{Grid, TriangleTree, voxelise};
@@ -119,6 +122,24 @@ pub struct Flow {
const NONE: u32 = u32::MAX;
/// Cells per parallel block of the collision and the streaming. The blocks are fixed, every
/// cell's update reads and writes only its own populations (collision) or writes only its own
/// (streaming), so the result does not depend on the number of threads.
const CELLS: usize = 4096;
/// The first `n` populations of each direction of an array with direction stride `stride`, cut
/// into blocks of [`CELLS`] cells: block b holds the Q directions' slices of cells
/// b·CELLS.. .
fn cell_blocks(data: &mut [f64], stride: usize, n: usize) -> Vec<Vec<&mut [f64]>> {
let mut blocks: Vec<Vec<&mut [f64]>> = (0..n.div_ceil(CELLS)).map(|_| Vec::with_capacity(Q)).collect();
for direction in data.chunks_mut(stride.max(1)).take(Q) {
for (block, cells) in blocks.iter_mut().zip(direction.get_mut(..n).unwrap_or_default().chunks_mut(CELLS)) {
block.push(cells);
}
}
blocks
}
/// A vector allocated without aborting when memory runs out.
fn filled<T: Clone>(len: usize, value: T, what: &str) -> Result<Vec<T>> {
let mut v = Vec::new();
@@ -423,102 +444,129 @@ impl Flow {
Ok(())
}
/// Two-relaxation-time collision with Guo forcing, in place. Returns the largest squared
/// lattice speed, or NaN when a density is not positive.
/// Two-relaxation-time collision with Guo forcing, in place, by blocks of [`CELLS`] cells
/// in parallel. Returns the largest squared lattice speed, or NaN when a density is not
/// positive.
fn collide(&mut self) -> f64 {
let n = self.n;
let s = self.stride;
let (wp, wm) = (self.omega_plus, self.omega_minus);
let (sp, sm) = (1.0 - 0.5 * wp, 1.0 - 0.5 * wm);
let a = self.acceleration;
let forced = a != [0.0; 3];
let mut max_u2: f64 = 0.0;
let mut fq = [0.0; Q];
for cell in 0..n {
if !self.fluid[cell] {
continue;
}
let (mut rho, mut j) = (0.0, [0.0; 3]);
for q in 0..Q {
let v = self.f[q * s + cell];
fq[q] = v;
let cq = C[q];
rho += v;
j[0] += v * f64::from(cq[0]);
j[1] += v * f64::from(cq[1]);
j[2] += v * f64::from(cq[2]);
}
if !(rho > 0.0 && rho.is_finite()) {
return f64::NAN;
}
let force = [rho * a[0], rho * a[1], rho * a[2]];
let u = [(j[0] + 0.5 * force[0]) / rho, (j[1] + 0.5 * force[1]) / rho, (j[2] + 0.5 * force[2]) / rho];
let uu = u[0] * u[0] + u[1] * u[1] + u[2] * u[2];
max_u2 = max_u2.max(uu);
let uf = u[0] * force[0] + u[1] * force[1] + u[2] * force[2];
// Rest population: symmetric only.
let eq0 = W[0] * rho * (1.0 - 1.5 * uu);
let mut out0 = fq[0] - wp * (fq[0] - eq0);
if forced {
out0 += sp * W[0] * (-3.0 * uf);
}
self.f[cell] = out0;
for q in (1..Q).step_by(2) {
let o = q + 1;
let cq = C[q];
let cv = [f64::from(cq[0]), f64::from(cq[1]), f64::from(cq[2])];
let cu = cv[0] * u[0] + cv[1] * u[1] + cv[2] * u[2];
let w = W[q];
let eq_plus = w * rho * (1.0 + 4.5 * cu * cu - 1.5 * uu);
let eq_minus = w * rho * 3.0 * cu;
let plus = 0.5 * (fq[q] + fq[o]);
let minus = 0.5 * (fq[q] - fq[o]);
let relax_plus = wp * (plus - eq_plus);
let relax_minus = wm * (minus - eq_minus);
let (mut s_plus, mut s_minus) = (0.0, 0.0);
let fluid = &self.fluid;
let mut blocks = cell_blocks(&mut self.f, self.stride, n);
let mut maxima = vec![0.0f64; blocks.len()];
let mut work: Vec<(&mut Vec<&mut [f64]>, &mut f64)> = blocks.iter_mut().zip(maxima.iter_mut()).collect();
parallel::for_each_mut(&mut work, |b, (f, max_u2)| {
let mut fq = [0.0; Q];
let len = f.first().map_or(0, |d| d.len());
for i in 0..len {
if !fluid.get(b * CELLS + i).copied().unwrap_or(false) {
continue;
}
let (mut rho, mut j) = (0.0, [0.0; 3]);
for q in 0..Q {
let v = f.get(q).and_then(|d| d.get(i)).copied().unwrap_or(0.0);
fq[q] = v;
let cq = C[q];
rho += v;
j[0] += v * f64::from(cq[0]);
j[1] += v * f64::from(cq[1]);
j[2] += v * f64::from(cq[2]);
}
if !(rho > 0.0 && rho.is_finite()) {
**max_u2 = f64::NAN;
return;
}
let force = [rho * a[0], rho * a[1], rho * a[2]];
let u = [(j[0] + 0.5 * force[0]) / rho, (j[1] + 0.5 * force[1]) / rho, (j[2] + 0.5 * force[2]) / rho];
let uu = u[0] * u[0] + u[1] * u[1] + u[2] * u[2];
**max_u2 = max_u2.max(uu);
let uf = u[0] * force[0] + u[1] * force[1] + u[2] * force[2];
let mut put = |q: usize, v: f64| {
if let Some(x) = f.get_mut(q).and_then(|d| d.get_mut(i)) {
*x = v;
}
};
// Rest population: symmetric only.
let eq0 = W[0] * rho * (1.0 - 1.5 * uu);
let mut out0 = fq[0] - wp * (fq[0] - eq0);
if forced {
let cf = cv[0] * force[0] + cv[1] * force[1] + cv[2] * force[2];
s_plus = sp * w * (9.0 * cu * cf - 3.0 * uf);
s_minus = sm * w * 3.0 * cf;
out0 += sp * W[0] * (-3.0 * uf);
}
put(0, out0);
for q in (1..Q).step_by(2) {
let o = q + 1;
let cq = C[q];
let cv = [f64::from(cq[0]), f64::from(cq[1]), f64::from(cq[2])];
let cu = cv[0] * u[0] + cv[1] * u[1] + cv[2] * u[2];
let w = W[q];
let eq_plus = w * rho * (1.0 + 4.5 * cu * cu - 1.5 * uu);
let eq_minus = w * rho * 3.0 * cu;
let plus = 0.5 * (fq[q] + fq[o]);
let minus = 0.5 * (fq[q] - fq[o]);
let relax_plus = wp * (plus - eq_plus);
let relax_minus = wm * (minus - eq_minus);
let (mut s_plus, mut s_minus) = (0.0, 0.0);
if forced {
let cf = cv[0] * force[0] + cv[1] * force[1] + cv[2] * force[2];
s_plus = sp * w * (9.0 * cu * cf - 3.0 * uf);
s_minus = sm * w * 3.0 * cf;
}
put(q, fq[q] - relax_plus - relax_minus + s_plus + s_minus);
put(o, fq[o] - relax_plus + relax_minus + s_plus - s_minus);
}
self.f[q * s + cell] = fq[q] - relax_plus - relax_minus + s_plus + s_minus;
self.f[o * s + cell] = fq[o] - relax_plus + relax_minus + s_plus - s_minus;
}
}
max_u2
});
// The largest speed of all blocks (a maximum does not depend on the order), or NaN.
if maxima.iter().any(|m| m.is_nan()) { f64::NAN } else { maxima.iter().fold(0.0, |m, x| m.max(*x)) }
}
/// Pull streaming into `g`, with the wall rules and the momentum exchange.
/// Pull streaming into `g`, with the wall rules (blocks of [`CELLS`] destination cells in
/// parallel), then the momentum exchange (in link order).
fn stream(&mut self) {
let n = self.n;
let s = self.stride;
// Every direction as one shifted copy: right for cells whose neighbours are all fluid
// cells in the box; the others are overwritten by their rules below.
for q in 0..Q {
let off = self.offset[q];
let (src, dst) = (&self.f[q * s..q * s + n], &mut self.g[q * s..q * s + n]);
if off >= 0 {
let o = off as usize;
if o < n {
dst[o..].copy_from_slice(&src[..n - o]);
}
} else {
let o = off.unsigned_abs();
if o < n {
dst[..n - o].copy_from_slice(&src[o..]);
let (f, links, offset) = (&self.f, &self.links, &self.offset);
let mut blocks = cell_blocks(&mut self.g, s, n);
parallel::for_each_mut(&mut blocks, |b, g| {
let start = b * CELLS;
// Every direction as one shifted copy: right for cells whose neighbours are all fluid
// cells in the box; the others are overwritten by their rules below.
for (q, dst) in g.iter_mut().enumerate() {
let off = offset.get(q).copied().unwrap_or(0);
let src = f.get(q * s..q * s + n).unwrap_or_default();
for (i, x) in dst.iter_mut().enumerate() {
if let Some(from) = (start + i).checked_add_signed(-off)
&& let Some(v) = src.get(from)
{
*x = *v;
}
}
}
}
let first = links.partition_point(|l| (l.cell as usize) < start);
for link in links.get(first..).unwrap_or_default().iter().take_while(|l| (l.cell as usize) < start + CELLS) {
let (cell, q) = (link.cell as usize, link.q as usize);
let v = match link.rule {
Rule::Pull { from } => f.get(q * s + from as usize).copied(),
Rule::Bounce { du, .. } => f.get(opposite(q) * s + cell).map(|out| out + du),
Rule::Reflect { from, q: q2, .. } => f.get(q2 as usize * s + from as usize).copied(),
// A placeholder the open boundary replaces.
Rule::Open => f.get(opposite(q) * s + cell).copied(),
};
if let (Some(v), Some(x)) = (v, g.get_mut(q).and_then(|d| d.get_mut(cell - start))) {
*x = v;
}
}
});
self.forces.fill([0.0; 3]);
for link in &self.links {
let (cell, q) = (link.cell as usize, link.q as usize);
let cq = c(q);
match link.rule {
Rule::Pull { from } => self.g[q * s + cell] = self.f[q * s + from as usize],
Rule::Bounce { group, du } => {
let out = self.f[opposite(q) * s + cell];
let back = out + du;
self.g[q * s + cell] = back;
if let Some(force) = self.forces.get_mut(group as usize) {
// c_out f* − c_in f' with c_out = −c_q.
for a in 0..3 {
@@ -529,7 +577,6 @@ impl Flow {
Rule::Reflect { from, q: q2, group } => {
let q2 = q2 as usize;
let out = self.f[q2 * s + from as usize];
self.g[q * s + cell] = out;
let c2 = c(q2);
if let Some(force) = self.forces.get_mut(group as usize) {
for a in 0..3 {
@@ -537,8 +584,7 @@ impl Flow {
}
}
}
// A placeholder the open boundary replaces.
Rule::Open => self.g[q * s + cell] = self.f[opposite(q) * s + cell],
Rule::Pull { .. } | Rule::Open => {}
}
}
}
+4 -2
View File
@@ -39,8 +39,10 @@
//! the book.
//!
//! # Determinism
//! Double precision, fixed loop orders, no threads and no parallel reductions: the same model
//! gives the same result bit for bit on every platform, wasm included.
//! Double precision and fixed loop orders. Collision and streaming run on threads by fixed blocks
//! of cells ([`modelcraft_parallel`]), each cell's update independent of the others; the wall
//! forces and every other sum run in a fixed order on one thread. The same model gives the same
//! result bit for bit on any number of threads and on every platform, wasm included.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
// Population arrays are indexed by cell and direction counts fixed when they are allocated.
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "modelcraft-parallel"
description = "ModelCraft's deterministic data parallelism: fixed work partitions on a thread pool, results independent of the thread count"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
# The browser build runs everything on the calling thread (same results, no threads).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = "1.12"
+264
View File
@@ -0,0 +1,264 @@
//! Deterministic data parallelism for ModelCraft's CPU solvers.
//!
//! The solvers promise the same result bit for bit on every run, platform and thread count
//! (AGENTS.md, Determinism). Threads keep that promise when every floating-point value is
//! computed by exactly one task in an order that does not depend on how many threads run them:
//!
//! - **Fixed partitions.** Work is split into blocks whose sizes are constants of the algorithm
//! (never derived from the number of threads), and each block is computed by one task.
//! - **Ordered results.** [`map`] returns the blocks' results in block order; whatever combines
//! them (sums, minima, scatters into a global vector) does so on the calling thread in that
//! order, or in the order the sequential algorithm used.
//! - **No shared accumulators.** Tasks write only into the disjoint slices they are handed
//! ([`for_each_mut`], [`chunks_mut`]); there are no atomics on floating-point values and no
//! reductions whose shape depends on scheduling (work stealing decides only *when* a block
//! runs, never *what* it computes).
//!
//! Under those rules a run on one thread and a run on many execute the same operations on the
//! same operands, so the results are identical; the browser build (`wasm32`) runs the same
//! blocks one after another on the calling thread.
//!
//! **Thread count.** [`with_threads`] runs a closure on a pool of that many threads (pools are
//! cached; at most [`MAX_THREADS`]). Outside it, the helpers use the default pool:
//! [`ENV`] (`MODELCRAFT_THREADS`) when it holds a whole number from 1 to [`MAX_THREADS`], else
//! the available cores.
//!
//! Rayon (MIT OR Apache-2.0) provides the pool (docs/adr/0001-libraries.md).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
/// Most threads a solve may use.
pub const MAX_THREADS: usize = 256;
/// The environment variable that sets the default thread count.
pub const ENV: &str = "MODELCRAFT_THREADS";
/// A thread count from text: a whole number from 1 to [`MAX_THREADS`].
pub fn parse_threads(text: &str) -> Option<usize> {
text.trim().parse::<usize>().ok().filter(|n| (1..=MAX_THREADS).contains(n))
}
/// The cores this process may use (at most [`MAX_THREADS`]; 1 in the browser).
pub fn available() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, MAX_THREADS)
}
#[cfg(target_arch = "wasm32")]
{
1
}
}
/// The default thread count: [`ENV`] when it is valid, else [`available`].
pub fn default_threads() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
std::env::var(ENV).ok().and_then(|v| parse_threads(&v)).unwrap_or_else(available)
}
#[cfg(target_arch = "wasm32")]
{
1
}
}
/// Run `f` with `threads` worker threads (clamped to 1..=[`MAX_THREADS`]) serving the helpers of
/// this crate. The result does not depend on `threads`; only the time does.
pub fn with_threads<R: Send>(threads: usize, f: impl FnOnce() -> R + Send) -> R {
pool::with_threads(threads.clamp(1, MAX_THREADS), f)
}
/// The number of threads the helpers would use here.
pub fn current_threads() -> usize {
pool::current_threads()
}
/// `f(i)` for `i` in `0..count`, run in parallel, results in index order.
pub fn map<T: Send>(count: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
pool::map(count, f)
}
/// `f(i)` for `i` in `0..count`, results in index order, run in parallel in blocks of `grain`
/// consecutive indices (one task per block: for cheap `f`).
pub fn map_grained<T: Send>(count: usize, grain: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
let ranges: Vec<std::ops::Range<usize>> = blocks(count, grain).collect();
let parts = map(ranges.len(), |k| ranges.get(k).cloned().unwrap_or(0..0).map(&f).collect::<Vec<T>>());
parts.into_iter().flatten().collect()
}
/// `f(i, item)` for every item, run in parallel; each task gets its own item mutably (use it to
/// hand tasks disjoint parts of the output).
pub fn for_each_mut<T: Send>(items: &mut [T], f: impl Fn(usize, &mut T) + Sync + Send) {
pool::for_each_mut(items, f);
}
/// `f(k, chunk)` for the consecutive chunks of `chunk` items of `data` (the last may be
/// shorter), run in parallel.
pub fn chunks_mut<T: Send>(data: &mut [T], chunk: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
let mut parts: Vec<&mut [T]> = data.chunks_mut(chunk.max(1)).collect();
for_each_mut(&mut parts, |k, part| f(k, part));
}
/// The ranges of `len` items in blocks of `block` (the last may be shorter).
pub fn blocks(len: usize, block: usize) -> impl Iterator<Item = std::ops::Range<usize>> + Clone {
let block = block.max(1);
(0..len.div_ceil(block)).map(move |k| k * block..((k + 1) * block).min(len))
}
#[cfg(not(target_arch = "wasm32"))]
mod pool {
use std::sync::{Arc, Mutex, OnceLock};
use rayon::prelude::*;
use rayon::{ThreadPool, ThreadPoolBuilder};
/// Pools kept for reuse, most recently used last; older ones are dropped (their threads
/// end) so a caller cycling through thread counts cannot pile up threads.
const CACHED: usize = 4;
/// Worker stack size: the element kernels keep small matrices on the stack.
const STACK: usize = 8 << 20;
static POOLS: Mutex<Vec<(usize, Arc<ThreadPool>)>> = Mutex::new(Vec::new());
static DEFAULT: OnceLock<Option<Arc<ThreadPool>>> = OnceLock::new();
fn build(threads: usize) -> Option<Arc<ThreadPool>> {
ThreadPoolBuilder::new().num_threads(threads).stack_size(STACK).thread_name(|i| format!("modelcraft-worker-{i}")).build().ok().map(Arc::new)
}
fn cached(threads: usize) -> Option<Arc<ThreadPool>> {
let mut pools = POOLS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(at) = pools.iter().position(|(n, _)| *n == threads) {
let entry = pools.remove(at);
let pool = entry.1.clone();
pools.push(entry);
return Some(pool);
}
let pool = build(threads)?;
if pools.len() >= CACHED {
pools.remove(0);
}
pools.push((threads, pool.clone()));
Some(pool)
}
fn default_pool() -> Option<Arc<ThreadPool>> {
DEFAULT.get_or_init(|| build(super::default_threads())).clone()
}
pub fn with_threads<R: Send>(threads: usize, f: impl FnOnce() -> R + Send) -> R {
match cached(threads) {
Some(pool) => pool.install(f),
// No threads to be had: the helpers fall back to the default pool or to the calling
// thread, with the same result.
None => f(),
}
}
/// Run `op` inside a pool: the current one, else the default one, else on this thread.
fn inside<R: Send>(op: impl FnOnce() -> R + Send) -> R {
if rayon::current_thread_index().is_some() {
return op();
}
match default_pool() {
Some(pool) => pool.install(op),
None => op(),
}
}
pub fn current_threads() -> usize {
if rayon::current_thread_index().is_some() {
return rayon::current_num_threads();
}
default_pool().map_or(1, |p| p.current_num_threads())
}
pub fn map<T: Send>(count: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
if count <= 1 {
return (0..count).map(f).collect();
}
inside(|| (0..count).into_par_iter().with_max_len(1).map(&f).collect())
}
pub fn for_each_mut<T: Send>(items: &mut [T], f: impl Fn(usize, &mut T) + Sync + Send) {
if items.len() <= 1 {
items.iter_mut().enumerate().for_each(|(i, x)| f(i, x));
return;
}
inside(|| items.par_iter_mut().with_max_len(1).enumerate().for_each(|(i, x)| f(i, x)));
}
}
#[cfg(target_arch = "wasm32")]
mod pool {
pub fn with_threads<R: Send>(_threads: usize, f: impl FnOnce() -> R + Send) -> R {
f()
}
pub fn current_threads() -> usize {
1
}
pub fn map<T: Send>(count: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
(0..count).map(f).collect()
}
pub fn for_each_mut<T: Send>(items: &mut [T], f: impl Fn(usize, &mut T) + Sync + Send) {
items.iter_mut().enumerate().for_each(|(i, x)| f(i, x));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A sum whose value depends on the order of its terms in floating point: the helpers must
/// give the sequential answer on any number of threads.
fn work() -> (Vec<f64>, f64) {
let terms: Vec<f64> = (0..10_000).map(|i| ((i as f64) * 0.618_033_988_7).sin() * 10f64.powi(i % 17 - 8)).collect();
let partial = map(blocks(terms.len(), 97).count(), |k| {
let r = blocks(terms.len(), 97).nth(k).unwrap_or(0..0);
terms[r].iter().sum::<f64>()
});
let total = partial.iter().sum::<f64>();
let mut scaled = terms.clone();
chunks_mut(&mut scaled, 61, |k, c| c.iter_mut().for_each(|x| *x *= 1.0 + k as f64 * 1e-3));
(scaled, total)
}
#[test]
fn results_do_not_depend_on_the_thread_count() {
let one = with_threads(1, work);
for threads in [2, 3, 7, 16] {
let other = with_threads(threads, work);
assert!(one.0.iter().zip(&other.0).all(|(a, b)| a.to_bits() == b.to_bits()));
assert_eq!(one.1.to_bits(), other.1.to_bits(), "{threads} threads");
}
assert_eq!(work().1.to_bits(), one.1.to_bits(), "the default pool");
}
#[test]
fn thread_counts_are_capped_and_parsed() {
assert_eq!(with_threads(0, current_threads), 1);
assert_eq!(with_threads(3, current_threads), 3);
assert_eq!(with_threads(usize::MAX, current_threads), MAX_THREADS);
assert_eq!(parse_threads(" 8 "), Some(8));
for bad in ["0", "-1", "257", "1e3", "", "lots", "18446744073709551616"] {
assert_eq!(parse_threads(bad), None, "{bad}");
}
assert!((1..=MAX_THREADS).contains(&default_threads()));
// Nested use runs on the pool it is in.
assert_eq!(with_threads(2, || map(3, |_| current_threads())), vec![2, 2, 2]);
assert_eq!(with_threads(3, || map_grained(10, 3, |i| i * i)), (0..10).map(|i| i * i).collect::<Vec<_>>());
assert_eq!(blocks(10, 4).collect::<Vec<_>>(), vec![0..4, 4..8, 8..10]);
assert_eq!(blocks(0, 4).count(), 0);
assert_eq!(blocks(3, 0).count(), 3);
}
#[test]
fn many_thread_counts_do_not_pile_up_pools() {
for threads in 1..=12 {
assert_eq!(with_threads(threads, current_threads), threads);
}
}
}
+1
View File
@@ -11,5 +11,6 @@ workspace = true
[dependencies]
modelcraft-model = { workspace = true }
modelcraft-fem = { workspace = true }
modelcraft-parallel = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
+47 -14
View File
@@ -9,6 +9,8 @@ use modelcraft_fem::{DofKind, DofLayout, PointStress, von_mises};
use modelcraft_model::{ElementKind, Model, Procedure, Resolved, Step};
use serde::Serialize;
use modelcraft_parallel as parallel;
use crate::cholesky::{Factor, FactorError, UpperColumns};
use crate::constraints::{Budget, Reduction, coupling_rows};
use crate::eigen::{Operator, Wanted, lanczos};
@@ -164,21 +166,46 @@ struct Assembled {
m: Option<Csr>,
}
/// Elements whose matrices are computed together before they are added.
const ROUND: usize = 4096;
/// Elements per parallel task.
const GRAIN: usize = 16;
/// Assemble the stiffness (and the mass when `mass` is set) on the mesh's node graph.
fn assemble(mesh: &Mesh, graph: &NodeGraph, mass: Option<MassKind>) -> Result<Assembled> {
let layout = &mesh.layout;
let mut k = graph.zero_matrix(layout);
let mut m = mass.map(|_| graph.zero_matrix(layout));
for e in &mesh.elements {
let kernel = e.kernel()?;
let kinds = kernel.dofs().node;
let at = |err| Error::element(e.label, err);
add_block(&mut k, layout, &e.nodes, kinds, &kernel.stiffness(&e.d).map_err(at)?.data)?;
if let (Some(mm), Some(kind)) = (m.as_mut(), mass) {
let rho = e.density.ok_or_else(|| Error::Invalid(format!("element {} has no density", e.label)))?;
match kind {
MassKind::Consistent => add_scalar(mm, layout, &e.nodes, kinds, &kernel.mass(rho).map_err(at)?.data)?,
MassKind::Lumped => add_diagonal(mm, layout, &e.nodes, kinds, &kernel.lumped_mass(rho).map_err(at)?)?,
// Element matrices are computed in parallel, a round at a time, and added in label order
// (the sums are those of a sequential loop).
for round in parallel::blocks(mesh.elements.len(), ROUND) {
let elements = mesh.elements.get(round).unwrap_or_default();
let matrices = parallel::map_grained(elements.len(), GRAIN, |i| -> Result<_> {
let Some(e) = elements.get(i) else { return Err(Error::Internal("element index".into())) };
let kernel = e.kernel()?;
let at = |err| Error::element(e.label, err);
let stiffness = kernel.stiffness(&e.d).map_err(at)?.data;
let element_mass = match mass {
None => None,
Some(kind) => {
let rho = e.density.ok_or_else(|| Error::Invalid(format!("element {} has no density", e.label)))?;
Some(match kind {
MassKind::Consistent => kernel.mass(rho).map_err(at)?.data,
MassKind::Lumped => kernel.lumped_mass(rho).map_err(at)?,
})
}
};
Ok((kernel.dofs().node, stiffness, element_mass))
});
for (e, computed) in elements.iter().zip(matrices) {
let (kinds, stiffness, element_mass) = computed?;
add_block(&mut k, layout, &e.nodes, kinds, &stiffness)?;
if let (Some(mm), Some(kind), Some(data)) = (m.as_mut(), mass, element_mass) {
match kind {
MassKind::Consistent => add_scalar(mm, layout, &e.nodes, kinds, &data)?,
MassKind::Lumped => add_diagonal(mm, layout, &e.nodes, kinds, &data)?,
}
}
}
}
@@ -224,6 +251,8 @@ fn factor_error(e: FactorError, mesh: &Mesh, reduction: &Reduction) -> Error {
FactorError::TooLarge { entries, limit } => {
Error::Limit(format!("the factorization needs {entries} entries; the limit is {limit} (refine less or raise the limit)"))
}
FactorError::OutOfMemory { entries } => Error::Limit(format!("not enough memory for the factorization ({entries} entries)")),
FactorError::Internal(what) => Error::Internal(what),
FactorError::NotPositiveDefinite { column } => {
let at = reduction.eq_dof.get(column).map_or_else(|| "an unknown degree of freedom".to_string(), |d| mesh.describe_dof(*d));
Error::Singular(format!(
@@ -370,14 +399,18 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
let mut elements = Vec::with_capacity(mesh.elements.len());
let mut sum = vec![[0.0; 6]; mesh.labels.len()];
let mut count = vec![0u32; mesh.labels.len()];
for e in &mesh.elements {
let all_points = parallel::map_grained(mesh.elements.len(), GRAIN, |i| -> Result<_> {
let Some(e) = mesh.elements.get(i) else { return Err(Error::Internal("element index".into())) };
let kernel = e.kernel()?;
let at = |err| Error::element(e.label, err);
let ue: Vec<[f64; 3]> = e.nodes.iter().map(|n| displacement.get(*n).copied().unwrap_or_default()).collect();
let thermal = field.as_deref().and_then(|t| e.thermal_strain(t));
let points = kernel.stresses_with(&e.d, &ue, thermal.as_ref()).map_err(at)?;
let points = kernel.stresses_with(&e.d, &ue, thermal.as_ref()).map_err(|err| Error::element(e.label, err))?;
Ok((points, kernel.ty(), kernel.rule()))
});
for (e, computed) in mesh.elements.iter().zip(all_points) {
let (points, ty, rule) = computed?;
let at = |err| Error::element(e.label, err);
let values: Vec<[f64; 6]> = points.iter().map(|p| p.stress).collect();
let (ty, rule) = (kernel.ty(), kernel.rule());
let cached = extrapolations.iter().position(|(t, r, _)| std::ptr::eq(*t, ty) && *r == rule);
let index = match cached {
Some(i) => i,
File diff suppressed because it is too large. Load diff
+7 -5
View File
@@ -184,7 +184,7 @@ pub struct Wanted {
}
/// The operators the Lanczos iteration needs.
pub trait Operator {
pub trait Operator: Sync {
fn n(&self) -> usize;
/// y = M x.
fn mass(&self, x: &[f64], y: &mut [f64]);
@@ -285,10 +285,12 @@ pub fn lanczos(op: &impl Operator, shift: f64, wanted: Wanted, block: usize, max
let j0 = q.len();
// W = Op Q_j.
let mut w: Vec<Vec<f64>> = Vec::with_capacity(b);
for mx in &mcur {
let mut y = mx.clone();
op.solve(&mut y)?;
w.push(y);
// The block's solves are independent: one task each.
for y in modelcraft_parallel::map(mcur.len(), |i| {
let mut y = mcur.get(i).cloned().unwrap_or_default();
op.solve(&mut y).map(|()| y)
}) {
w.push(y?);
}
// A_j = Q_jᵀ M W (symmetrized), W −= Q_j A_j (the earlier blocks go in the full
// reorthogonalization below).
+2
View File
@@ -308,6 +308,8 @@ fn factor(a: &Csr, limits: &Limits, mesh: &HeatMesh, eqs: &Equations) -> Result<
FactorError::TooLarge { entries, limit } => {
Error::Limit(format!("the factorization needs {entries} entries; the limit is {limit} (refine less or raise the limit)"))
}
FactorError::OutOfMemory { entries } => Error::Limit(format!("not enough memory for the factorization ({entries} entries)")),
FactorError::Internal(what) => Error::Internal(what),
FactorError::NotPositiveDefinite { column } => {
let label = eqs.node.get(column).and_then(|n| mesh.labels.get(*n)).copied().unwrap_or(0);
Error::Singular(format!(
+5 -2
View File
@@ -15,8 +15,11 @@
//! von Mises. Frequency analysis ([`eigen`]) runs shift-invert block Lanczos on the same
//! kind of factorization.
//!
//! Everything is sequential and in a fixed order, so the same model gives the same numbers bit
//! for bit; sizes and iterations are capped by [`Limits`].
//! Element matrices and stresses, the factorization's subtrees and dense tiles, the Lanczos
//! block solves and matrix products run on threads ([`modelcraft_parallel`]), but every number
//! is computed by one task in an order fixed by the model alone and sums are taken in a fixed
//! order, so the same model gives the same numbers bit for bit on any number of threads and on
//! every platform; sizes and iterations are capped by [`Limits`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
// Matrix kernels read as the textbook formulas with index loops.
+11 -4
View File
@@ -11,6 +11,9 @@ use modelcraft_fem::{DofLayout, DofSet};
use crate::{Error, Result};
/// Rows per parallel block of a product.
const ROWS: usize = 4096;
/// A square sparse matrix in compressed rows with sorted column indices.
#[derive(Debug, Clone, PartialEq)]
pub struct Csr {
@@ -28,11 +31,15 @@ impl Csr {
}
/// y = A x.
/// Rows are computed in parallel blocks; each row's sum runs in column order.
pub fn mul(&self, x: &[f64], y: &mut [f64]) {
for (r, yr) in y.iter_mut().enumerate().take(self.n) {
let (cols, vals) = self.row(r);
*yr = cols.iter().zip(vals).map(|(c, v)| v * x.get(*c).copied().unwrap_or(0.0)).sum();
}
let n = self.n.min(y.len());
modelcraft_parallel::chunks_mut(y.get_mut(..n).unwrap_or_default(), ROWS, |b, block| {
for (i, yr) in block.iter_mut().enumerate() {
let (cols, vals) = self.row(b * ROWS + i);
*yr = cols.iter().zip(vals).map(|(c, v)| v * x.get(*c).copied().unwrap_or(0.0)).sum();
}
});
}
pub fn diagonal(&self) -> Vec<f64> {