forked from 1b-agent/fieldcraft-upstream
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:
25 files changed
+1710
-342
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -11,4 +11,5 @@ workspace = true
|
||||
[dependencies]
|
||||
modelcraft-model = { workspace = true }
|
||||
modelcraft-mesh = { workspace = true }
|
||||
modelcraft-parallel = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
+123
-77
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
|
||||
+808
-118
File diff suppressed because it is too large.
Load diff
@@ -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).
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
Reference in new issue
Block a user