Files
rustytorch/crates/specialized/rtx-cfd/src/solvers/incompressible/poisson/device.rs
T
Omar SobhandClaude Fable 5.1 95ffde9591
CI / Python Bindings (maturin) (ubuntu-latest) (push) Blocked by required conditions
CI / WASM Build + Size Check (push) Blocked by required conditions
CI / Distributed Training Tests (push) Blocked by required conditions
CI / CI Success (push) Blocked by required conditions
CI / Build (macos-latest) (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (macos-latest) (push) Blocked by required conditions
CI / Format Check (push) Failing after 5s
Documentation / Build API Documentation (push) Failing after 5s
CI / Build (ubuntu-latest) (push) Failing after 14s
CI / Clippy Check (push) Failing after 3m1s
Documentation / Build User Guide (push) Successful in 6s
Performance Benchmarks / Run Benchmarks (push) Failing after 11s
CI / Build CPU-Only (Explicit) (push) Failing after 2m33s
rtx-cfd/rtx-fsi PERF-2 P3-iii: the MarchPlane — lane-table V-cycle kernels (ml_*), the batched apply over K lanes' own operators, the plane rendezvous inside the CG's preconditioner (lane thread-local, CG guard), set_plane_lane/plane_counters; the fsi2_overset_plane driver (K lane threads, one process); G-P0 unit tests: K distinct operators batched = solo apply bit for bit, uneven lanes served exactly
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-16 17:03:51 -05:00

879 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! PERF-2 P3-ii (`docs/perf2_campaign.md`): the multigrid V-cycle on the
//! CUDA device as the CG's preconditioner — the `mg_vcycle.cu` kernels of
//! the go/no-go benchmark, K = 1, with persistent device buffers per
//! operator. One CUDA context, stream and module per process (built on
//! first use); the CG, its residual and its stop stay f64 on the CPU.
use super::LevelExport;
use cudarc::driver::{
CudaContext, CudaFunction, CudaModule, CudaSlice, CudaStream, DevicePtr, DeviceRepr,
LaunchConfig, PushKernelArg, ValidAsZeroBits,
};
use cudarc::nvrtc::{CompileOptions, compile_ptx_with_opts};
use std::cell::Cell;
use std::collections::BTreeMap;
use std::sync::{Arc, Condvar, Mutex, OnceLock};
const KERNELS: &str = include_str!("../../../kernels/cuda/mg_vcycle.cu");
struct Runtime {
_ctx: Arc<CudaContext>,
stream: Arc<CudaStream>,
_module: Arc<CudaModule>,
f_half: CudaFunction,
f_res: CudaFunction,
f_restrict: CudaFunction,
f_prolong: CudaFunction,
f_zero: CudaFunction,
f_coarsest: CudaFunction,
// The lane-table kernels (P3-iii).
l_half: CudaFunction,
l_res: CudaFunction,
l_restrict: CudaFunction,
l_prolong: CudaFunction,
l_zero: CudaFunction,
l_coarsest: CudaFunction,
}
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
fn runtime() -> &'static Runtime {
RUNTIME.get_or_init(|| {
let ctx = CudaContext::new(0).expect("CUDA context (device 0)");
let stream = ctx.default_stream();
let arch = std::env::var("RTX_CUDA_ARCH").unwrap_or_else(|_| "sm_120".to_string());
let ptx = compile_ptx_with_opts(
KERNELS,
CompileOptions {
arch: Some(Box::leak(arch.into_boxed_str())),
..Default::default()
},
)
.expect("nvrtc: mg_vcycle.cu");
let module = ctx.load_module(ptx).expect("mg_vcycle module");
let f = |name: &str| module.load_function(name).expect(name);
Runtime {
f_half: f("mg_rb_half"),
f_res: f("mg_residual"),
f_restrict: f("mg_restrict"),
f_prolong: f("mg_prolong"),
f_zero: f("mg_zero"),
f_coarsest: f("mg_coarsest"),
l_half: f("ml_rb_half"),
l_res: f("ml_residual"),
l_restrict: f("ml_restrict"),
l_prolong: f("ml_prolong"),
l_zero: f("ml_zero"),
l_coarsest: f("ml_coarsest"),
_ctx: ctx,
stream,
_module: module,
}
})
}
struct DevLevel {
n: usize,
nx: i32,
n_cells: usize,
n_red: usize,
n_black: usize,
cells: CudaSlice<u32>,
red: CudaSlice<u32>,
black: CudaSlice<u32>,
coarse_of: CudaSlice<u32>,
children_ptr: CudaSlice<u32>,
children_idx: CudaSlice<u32>,
ae: CudaSlice<f32>,
aw: CudaSlice<f32>,
an: CudaSlice<f32>,
as_: CudaSlice<f32>,
ap: CudaSlice<f32>,
b: CudaSlice<f32>,
x: CudaSlice<f32>,
r: CudaSlice<f32>,
}
/// One operator's hierarchy on the device (K = 1).
pub(super) struct DeviceVcycle {
levels: Vec<DevLevel>,
sweeps: usize,
fine_cells: Vec<u32>,
r_f32: Vec<f32>,
z_f32: Vec<f32>,
}
fn cfg(n_items: usize) -> LaunchConfig {
LaunchConfig {
grid_dim: ((n_items as u32).div_ceil(256).max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
}
}
impl DeviceVcycle {
pub(super) fn new(levels: &[LevelExport], sweeps: usize) -> Self {
let rt = runtime();
let up_u = |v: &[u32]| -> CudaSlice<u32> {
rt.stream
.memcpy_stod(if v.is_empty() { &[0u32][..] } else { v })
.expect("upload")
};
let up_f = |v: &[f32]| -> CudaSlice<f32> { rt.stream.memcpy_stod(v).expect("upload") };
let dev: Vec<DevLevel> = levels
.iter()
.map(|l| {
let n = l.nx * l.ny;
DevLevel {
n,
nx: l.nx as i32,
n_cells: l.cells.len(),
n_red: l.red.len(),
n_black: l.black.len(),
cells: up_u(&l.cells),
red: up_u(&l.red),
black: up_u(&l.black),
coarse_of: up_u(&l.coarse_of),
children_ptr: up_u(&l.children_ptr),
children_idx: up_u(&l.children_idx),
ae: up_f(&l.ae),
aw: up_f(&l.aw),
an: up_f(&l.an),
as_: up_f(&l.as_),
ap: up_f(&l.ap),
b: rt.stream.alloc_zeros::<f32>(n).expect("alloc"),
x: rt.stream.alloc_zeros::<f32>(n).expect("alloc"),
r: rt.stream.alloc_zeros::<f32>(n).expect("alloc"),
}
})
.collect();
let n0 = dev[0].n;
Self {
levels: dev,
sweeps,
fine_cells: levels[0].cells.clone(),
r_f32: vec![0.0; n0],
z_f32: vec![0.0; n0],
}
}
fn half(&mut self, l: usize, colour: u8) {
let rt = runtime();
let lv = &mut self.levels[l];
let (list, n_list) = if colour == 0 {
(&lv.red, lv.n_red)
} else {
(&lv.black, lv.n_black)
};
let (n_i, n_list_i) = (lv.n as i32, n_list as i32);
unsafe {
rt.stream
.launch_builder(&rt.f_half)
.arg(&n_list_i)
.arg(list)
.arg(&n_i)
.arg(&lv.ae)
.arg(&lv.aw)
.arg(&lv.an)
.arg(&lv.as_)
.arg(&lv.ap)
.arg(&lv.b)
.arg(&mut lv.x)
.arg(&lv.nx)
.launch(cfg(n_list))
.expect("mg_rb_half");
}
}
fn smooth(&mut self, l: usize) {
for _ in 0..self.sweeps {
self.half(l, 0);
self.half(l, 1);
self.half(l, 1);
self.half(l, 0);
}
}
/// `z = M⁻¹ r` on the active cells (the same V-cycle as
/// `Hierarchy::apply_preconditioner`, in f32 on the device).
pub(super) fn apply(&mut self, r: &[f64], z: &mut [f64]) {
let rt = runtime();
let depth = self.levels.len();
for (dst, &src) in self.r_f32.iter_mut().zip(r) {
*dst = src as f32;
}
rt.stream
.memcpy_htod(&self.r_f32, &mut self.levels[0].b)
.expect("upload r");
// Down.
for l in 0..depth - 1 {
{
let lv = &mut self.levels[l];
let (n_i, n_cells_i) = (lv.n as i32, lv.n_cells as i32);
unsafe {
rt.stream
.launch_builder(&rt.f_zero)
.arg(&n_cells_i)
.arg(&lv.cells)
.arg(&n_i)
.arg(&mut lv.x)
.launch(cfg(lv.n_cells))
.expect("mg_zero");
}
}
self.smooth(l);
{
let lv = &mut self.levels[l];
let (n_i, n_cells_i) = (lv.n as i32, lv.n_cells as i32);
unsafe {
rt.stream
.launch_builder(&rt.f_res)
.arg(&n_cells_i)
.arg(&lv.cells)
.arg(&n_i)
.arg(&lv.ae)
.arg(&lv.aw)
.arg(&lv.an)
.arg(&lv.as_)
.arg(&lv.ap)
.arg(&lv.b)
.arg(&lv.x)
.arg(&mut lv.r)
.arg(&lv.nx)
.launch(cfg(lv.n_cells))
.expect("mg_residual");
}
}
let (fine, coarse) = self.levels.split_at_mut(l + 1);
let (lf, lc) = (&fine[l], &mut coarse[0]);
let (n_c_cells_i, n_f_i, n_c_i) = (lc.n_cells as i32, lf.n as i32, lc.n as i32);
unsafe {
rt.stream
.launch_builder(&rt.f_restrict)
.arg(&n_c_cells_i)
.arg(&lc.cells)
.arg(&lf.children_ptr)
.arg(&lf.children_idx)
.arg(&n_f_i)
.arg(&n_c_i)
.arg(&lf.r)
.arg(&mut lc.b)
.launch(cfg(lc.n_cells))
.expect("mg_restrict");
}
}
// Coarsest.
{
let lv = &mut self.levels[depth - 1];
let (k_i, n_cells_i, n_i, sw_i) = (1i32, lv.n_cells as i32, lv.n as i32, 50i32);
let (n_red_i, n_black_i) = (lv.n_red as i32, lv.n_black as i32);
unsafe {
rt.stream
.launch_builder(&rt.f_coarsest)
.arg(&k_i)
.arg(&n_cells_i)
.arg(&lv.cells)
.arg(&n_red_i)
.arg(&lv.red)
.arg(&n_black_i)
.arg(&lv.black)
.arg(&n_i)
.arg(&lv.ae)
.arg(&lv.aw)
.arg(&lv.an)
.arg(&lv.as_)
.arg(&lv.ap)
.arg(&lv.b)
.arg(&mut lv.x)
.arg(&lv.nx)
.arg(&sw_i)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
})
.expect("mg_coarsest");
}
}
// Up.
for l in (0..depth - 1).rev() {
{
let (fine, coarse) = self.levels.split_at_mut(l + 1);
let (lf, lc) = (&mut fine[l], &coarse[0]);
let (n_cells_i, n_f_i, n_c_i) = (lf.n_cells as i32, lf.n as i32, lc.n as i32);
unsafe {
rt.stream
.launch_builder(&rt.f_prolong)
.arg(&n_cells_i)
.arg(&lf.cells)
.arg(&lf.coarse_of)
.arg(&n_f_i)
.arg(&n_c_i)
.arg(&mut lf.x)
.arg(&lc.x)
.launch(cfg(lf.n_cells))
.expect("mg_prolong");
}
}
self.smooth(l);
}
rt.stream
.memcpy_dtoh(&self.levels[0].x, &mut self.z_f32)
.expect("download z");
rt.stream.synchronize().expect("sync");
for &idx in &self.fine_cells {
z[idx as usize] = self.z_f32[idx as usize] as f64;
}
}
}
// ---------------------------------------------------------------------------
// PERF-2 P3-iii: the `MarchPlane` — K lanes' V-cycles in ONE batched launch
// set, and the rendezvous that gathers the lanes' preconditioner calls.
// ---------------------------------------------------------------------------
/// One lane's level as the `ml_*` kernels read it: 14 device pointers then
/// 6 ints (`n, nx, n_cells, n_red, n_black, pad`), mirroring `struct
/// LaneLevel` in `mg_vcycle.cu`.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct LaneLevel {
ptrs: [u64; 14],
ints: [i32; 6],
}
unsafe impl DeviceRepr for LaneLevel {}
unsafe impl ValidAsZeroBits for LaneLevel {}
impl DeviceVcycle {
pub(super) fn depth(&self) -> usize {
self.levels.len()
}
fn lane_level(&self, l: usize, stream: &CudaStream) -> LaneLevel {
let lv = &self.levels[l];
let pf = |s: &CudaSlice<f32>| s.device_ptr(stream).0;
let pu = |s: &CudaSlice<u32>| s.device_ptr(stream).0;
LaneLevel {
ptrs: [
pf(&lv.ae),
pf(&lv.aw),
pf(&lv.an),
pf(&lv.as_),
pf(&lv.ap),
pf(&lv.b),
pf(&lv.x),
pf(&lv.r),
pu(&lv.cells),
pu(&lv.red),
pu(&lv.black),
pu(&lv.coarse_of),
pu(&lv.children_ptr),
pu(&lv.children_idx),
],
ints: [
lv.n as i32,
lv.nx,
lv.n_cells as i32,
lv.n_red as i32,
lv.n_black as i32,
0,
],
}
}
}
/// One lane's preconditioner call: its operator on the device, the residual
/// in, the correction out.
pub(super) struct LaneJob<'a> {
pub dev: &'a mut DeviceVcycle,
pub r: &'a [f64],
pub z: &'a mut [f64],
}
fn cfg_k(n_items: usize, k: usize) -> LaunchConfig {
LaunchConfig {
grid_dim: ((n_items as u32).div_ceil(256).max(1), k as u32, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
}
}
/// `z_k = M_k⁻¹ r_k` for every job, the lanes of equal hierarchy depth in
/// one batched launch set (grid.y = the lane). Each lane's result is the
/// K = 1 [`DeviceVcycle::apply`]'s, bit for bit (the same per-cell
/// arithmetic in the same order; gate G-P0).
pub(super) fn apply_batch(jobs: &mut [LaneJob<'_>]) {
let rt = runtime();
let stream = &rt.stream;
let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (i, j) in jobs.iter().enumerate() {
groups.entry(j.dev.depth()).or_default().push(i);
}
for (depth, members) in groups {
let k = members.len();
// Residuals up.
for &i in &members {
let job = &mut jobs[i];
for (dst, &src) in job.dev.r_f32.iter_mut().zip(job.r) {
*dst = src as f32;
}
stream
.memcpy_htod(&job.dev.r_f32, &mut job.dev.levels[0].b)
.expect("upload r");
}
// The lane tables, one per level.
let tables: Vec<CudaSlice<LaneLevel>> = (0..depth)
.map(|l| {
let rows: Vec<LaneLevel> = members
.iter()
.map(|&i| jobs[i].dev.lane_level(l, stream))
.collect();
stream.memcpy_stod(&rows).expect("lane table")
})
.collect();
let max_of = |f: &dyn Fn(&DevLevel) -> usize, l: usize| -> usize {
members
.iter()
.map(|&i| f(&jobs[i].dev.levels[l]))
.max()
.unwrap_or(0)
};
let sweeps = jobs[members[0]].dev.sweeps;
let smooth = |l: usize| {
let n_red = max_of(&|lv| lv.n_red, l);
let n_black = max_of(&|lv| lv.n_black, l);
let half = |colour: i32| {
let n = if colour == 0 { n_red } else { n_black };
unsafe {
stream
.launch_builder(&rt.l_half)
.arg(&tables[l])
.arg(&colour)
.launch(cfg_k(n, k))
.expect("ml_rb_half");
}
};
for _ in 0..sweeps {
half(0);
half(1);
half(1);
half(0);
}
};
// Down.
for l in 0..depth - 1 {
let n_cells = max_of(&|lv| lv.n_cells, l);
unsafe {
stream
.launch_builder(&rt.l_zero)
.arg(&tables[l])
.launch(cfg_k(n_cells, k))
.expect("ml_zero");
}
smooth(l);
unsafe {
stream
.launch_builder(&rt.l_res)
.arg(&tables[l])
.launch(cfg_k(n_cells, k))
.expect("ml_residual");
}
let n_coarse = max_of(&|lv| lv.n_cells, l + 1);
unsafe {
stream
.launch_builder(&rt.l_restrict)
.arg(&tables[l])
.arg(&tables[l + 1])
.launch(cfg_k(n_coarse, k))
.expect("ml_restrict");
}
}
// Coarsest.
{
let (k_i, sw_i) = (k as i32, 50i32);
unsafe {
stream
.launch_builder(&rt.l_coarsest)
.arg(&tables[depth - 1])
.arg(&k_i)
.arg(&sw_i)
.launch(LaunchConfig {
grid_dim: ((k as u32).div_ceil(32), 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
})
.expect("ml_coarsest");
}
}
// Up.
for l in (0..depth - 1).rev() {
let n_cells = max_of(&|lv| lv.n_cells, l);
unsafe {
stream
.launch_builder(&rt.l_prolong)
.arg(&tables[l])
.arg(&tables[l + 1])
.launch(cfg_k(n_cells, k))
.expect("ml_prolong");
}
smooth(l);
}
// Corrections down.
for &i in &members {
let job = &mut jobs[i];
stream
.memcpy_dtoh(&job.dev.levels[0].x, &mut job.dev.z_f32)
.expect("download z");
}
stream.synchronize().expect("sync");
for &i in &members {
let job = &mut jobs[i];
for &idx in &job.dev.fine_cells {
job.z[idx as usize] = job.dev.z_f32[idx as usize] as f64;
}
}
}
}
// --- The rendezvous ---------------------------------------------------------
//
// K lane threads each run an unchanged march. A lane inside a CG (between
// `cg_enter` and the guard's drop) that calls the preconditioner submits its
// job and blocks; when every lane currently inside a CG has submitted, the
// last arrival serves the whole batch and wakes the rest. A lane LEAVING its
// CG can complete a batch too (the others were waiting for it). Lanes
// outside a CG are not counted, so they never hold a batch up.
thread_local! {
static LANE: Cell<Option<usize>> = const { Cell::new(None) };
}
/// Mark this thread as plane lane `lane` (`None` = a solo march: the K = 1
/// path, unchanged).
pub fn set_lane(lane: Option<usize>) {
LANE.with(|l| l.set(lane));
}
pub fn lane() -> Option<usize> {
LANE.with(|l| l.get())
}
struct PendingJob {
dev: *mut DeviceVcycle,
r: *const f64,
r_len: usize,
z: *mut f64,
z_len: usize,
}
// The submitting thread blocks until its job is served, so the pointers
// outlive the batch.
unsafe impl Send for PendingJob {}
#[derive(Default)]
struct PlaneState {
in_cg: usize,
pending: Vec<PendingJob>,
generation: u64,
/// Batches served and lanes served (the plane's own bookkeeping).
batches: u64,
lanes_served: u64,
}
static PLANE: Mutex<PlaneState> = Mutex::new(PlaneState {
in_cg: 0,
pending: Vec::new(),
generation: 0,
batches: 0,
lanes_served: 0,
});
static PLANE_CV: Condvar = Condvar::new();
fn plane_lock() -> std::sync::MutexGuard<'static, PlaneState> {
PLANE.lock().unwrap_or_else(|e| e.into_inner())
}
fn serve(st: &mut PlaneState) {
let pending = std::mem::take(&mut st.pending);
let mut jobs: Vec<LaneJob<'_>> = pending
.iter()
.map(|p| unsafe {
LaneJob {
dev: &mut *p.dev,
r: std::slice::from_raw_parts(p.r, p.r_len),
z: std::slice::from_raw_parts_mut(p.z, p.z_len),
}
})
.collect();
apply_batch(&mut jobs);
st.batches += 1;
st.lanes_served += pending.len() as u64;
st.generation += 1;
PLANE_CV.notify_all();
}
/// Held by a lane for the duration of one CG solve.
pub(super) struct CgGuard(());
impl Drop for CgGuard {
fn drop(&mut self) {
let mut st = plane_lock();
st.in_cg -= 1;
if st.in_cg > 0 && st.pending.len() >= st.in_cg {
serve(&mut st);
}
}
}
/// Enter a CG solve on this lane (`None` when this thread is not a lane).
pub(super) fn cg_enter() -> Option<CgGuard> {
lane()?;
plane_lock().in_cg += 1;
Some(CgGuard(()))
}
/// This lane's preconditioner call inside the plane: submit, and either
/// serve the batch (last arrival) or wait for it.
pub(super) fn apply_lane(dev: &mut DeviceVcycle, r: &[f64], z: &mut [f64]) {
let mut st = plane_lock();
let my_generation = st.generation;
st.pending.push(PendingJob {
dev: std::ptr::from_mut(dev),
r: r.as_ptr(),
r_len: r.len(),
z: z.as_mut_ptr(),
z_len: z.len(),
});
if st.pending.len() >= st.in_cg {
serve(&mut st);
return;
}
while st.generation == my_generation {
st = PLANE_CV.wait(st).unwrap_or_else(|e| e.into_inner());
}
}
/// `(batches served, lane calls served)` so far in this process.
pub fn plane_counters() -> (u64, u64) {
let st = plane_lock();
(st.batches, st.lanes_served)
}
#[cfg(test)]
mod tests {
//! G-P0 (`docs/perf2_campaign.md`, P3-iii): K distinct masked operators
//! through the batched path equal each one's K = 1 device apply bit for
//! bit, and the rendezvous serves uneven lanes exactly. GPU only:
//! `RTX_CUDA_ARCH=sm_120 cargo test --release -p rtx-cfd --features cuda --lib device::tests`.
use super::*;
use crate::solvers::incompressible::poisson::{
MgSmoother, MultigridParameters, PoissonProblem, export_hierarchy,
};
/// A masked channel operator with a hole at `(cx, cy)` and a seeded
/// right-hand side (the go/no-go benchmark's construction).
fn problem(nx: usize, ny: usize, cx: f64, cy: f64, seed: u64) -> PoissonProblem {
let mut p = PoissonProblem::new(nx, ny);
let (dx, dy, dt) = (2.5 / nx as f64, 0.41 / ny as f64, 3.24e-4);
let (ae, an) = (dt * dy / dx, dt * dx / dy);
let hole = |i: usize, j: usize| {
let (x, y) = ((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dy);
(x - cx).powi(2) + (y - cy).powi(2) < 0.08 * 0.08
};
for j in 0..ny {
for i in 0..nx {
let idx = j * nx + i;
if hole(i, j) {
p.active[idx] = false;
continue;
}
if i + 1 < nx && !hole(i + 1, j) {
p.ae[idx] = ae;
}
if i > 0 && !hole(i - 1, j) {
p.aw[idx] = ae;
}
if j + 1 < ny && !hole(i, j + 1) {
p.an[idx] = an;
}
if j > 0 && !hole(i, j - 1) {
p.as_[idx] = an;
}
if i + 1 == nx {
p.extra_diag[idx] = 2.0 * ae;
}
}
}
let mut state = seed | 1;
for idx in 0..nx * ny {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
p.rhs[idx] = if p.active[idx] {
1e-3 * ((state >> 11) as f64 / (1u64 << 53) as f64 - 0.5)
} else {
0.0
};
}
p
}
fn params() -> MultigridParameters {
MultigridParameters {
smoother: MgSmoother::RedBlack,
..MultigridParameters::default()
}
}
fn device_of(p: &PoissonProblem) -> DeviceVcycle {
DeviceVcycle::new(
&export_hierarchy(p, &params()),
params().smoother_sweeps.max(1),
)
}
fn bits(v: &[f64]) -> Vec<u64> {
v.iter().map(|x| x.to_bits()).collect()
}
/// K operators with different holes: solo applies, then one batch.
#[test]
fn batched_lanes_equal_the_solo_apply_bit_for_bit() {
let (nx, ny) = (186usize, 31usize);
let probs: Vec<PoissonProblem> = [(0.2, 0.2, 3u64), (0.6, 0.15, 7), (1.1, 0.25, 11)]
.iter()
.map(|&(cx, cy, s)| problem(nx, ny, cx, cy, s))
.collect();
let n = nx * ny;
let mut solo: Vec<Vec<f64>> = Vec::new();
for p in &probs {
let mut d = device_of(p);
let mut z = vec![0.0; n];
d.apply(&p.rhs, &mut z);
solo.push(z);
}
assert!(
solo[0].iter().any(|v| *v != 0.0),
"the solo apply produced zeros"
);
// K = 1 through the batched path (the lane kernels vs the K = 1 kernels).
{
let mut d = device_of(&probs[0]);
let mut z = vec![0.0; n];
apply_batch(&mut [LaneJob {
dev: &mut d,
r: &probs[0].rhs,
z: &mut z,
}]);
assert_eq!(
bits(&z),
bits(&solo[0]),
"K = 1 through the lane kernels differs"
);
}
// K = 3.
let mut devs: Vec<DeviceVcycle> = probs.iter().map(device_of).collect();
let mut zs: Vec<Vec<f64>> = vec![vec![0.0; n]; 3];
{
let mut jobs: Vec<LaneJob<'_>> = devs
.iter_mut()
.zip(probs.iter())
.zip(zs.iter_mut())
.map(|((dev, p), z)| LaneJob { dev, r: &p.rhs, z })
.collect();
apply_batch(&mut jobs);
}
for k in 0..3 {
assert_eq!(
bits(&zs[k]),
bits(&solo[k]),
"lane {k} differs from its solo apply"
);
}
// A second batch on the same devices (persistent buffers) with the
// lanes in another order and one lane left out.
let mut z1 = vec![0.0; n];
let mut z2 = vec![0.0; n];
{
let (a, b) = devs.split_at_mut(2);
let mut jobs = [
LaneJob {
dev: &mut b[0],
r: &probs[2].rhs,
z: &mut z2,
},
LaneJob {
dev: &mut a[1],
r: &probs[1].rhs,
z: &mut z1,
},
];
apply_batch(&mut jobs);
}
assert_eq!(bits(&z2), bits(&solo[2]));
assert_eq!(bits(&z1), bits(&solo[1]));
}
/// Three lane threads with 2 / 4 / 5 preconditioner calls each inside
/// one CG guard: every call's correction equals the solo apply of the
/// same residual, the lanes leave at different times, and no thread
/// hangs.
#[test]
fn the_rendezvous_serves_uneven_lanes_exactly() {
let (nx, ny) = (124usize, 21usize);
let n = nx * ny;
let probs: Vec<PoissonProblem> = [(0.2, 0.2, 5u64), (0.7, 0.2, 9), (1.3, 0.22, 13)]
.iter()
.map(|&(cx, cy, s)| problem(nx, ny, cx, cy, s))
.collect();
let calls = [2usize, 4, 5];
// Expected: the solo apply of rhs × (1 + c) for call c.
let mut expected: Vec<Vec<Vec<u64>>> = Vec::new();
for (k, p) in probs.iter().enumerate() {
let mut d = device_of(p);
let mut per_call = Vec::new();
for c in 0..calls[k] {
let r: Vec<f64> = p.rhs.iter().map(|v| v * (1.0 + c as f64)).collect();
let mut z = vec![0.0; n];
d.apply(&r, &mut z);
per_call.push(bits(&z));
}
expected.push(per_call);
}
let (b0, l0) = plane_counters();
let handles: Vec<_> = probs
.into_iter()
.enumerate()
.map(|(k, p)| {
let calls_k = calls[k];
std::thread::spawn(move || {
set_lane(Some(k));
let mut d = device_of(&p);
let guard = cg_enter().expect("lane set");
let mut out = Vec::new();
for c in 0..calls_k {
let r: Vec<f64> = p.rhs.iter().map(|v| v * (1.0 + c as f64)).collect();
let mut z = vec![0.0; n];
apply_lane(&mut d, &r, &mut z);
out.push(bits(&z));
}
drop(guard);
out
})
})
.collect();
let results: Vec<Vec<Vec<u64>>> = handles
.into_iter()
.map(|h| h.join().expect("lane thread"))
.collect();
for k in 0..3 {
assert_eq!(results[k].len(), calls[k]);
for c in 0..calls[k] {
assert_eq!(results[k][c], expected[k][c], "lane {k} call {c} differs");
}
}
let (b1, l1) = plane_counters();
assert_eq!(l1 - l0, 11, "every lane call served once");
assert!(b1 - b0 >= 5 && b1 - b0 <= 11, "batches {}", b1 - b0);
}
}