1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
//! Batched multi-channel evolve for 1-D grid kernels (ADR-0194, Issue #19).
//!
//! ADR-0184 gave the **graph** kernels a batched path; the **grid** 1-D family
//! (`ShiftChernoff1D`, `DiffusionChernoff`, `Diffusion4thChernoff`,
//! `DriftReactionChernoff`, …) still evolved one state per object. Three
//! quant-finance workloads degrade to Python loops without it: pricing a strike
//! strip is 11–50 independent solves under the *same* generator with only `u0`
//! differing; bump Greeks are ±bump re-solves that could amortise the
//! coefficient setup; and a Fokker–Planck backtest evolves ~250 density anchors
//! under one local-vol operator, paying object construction and a GIL round-trip
//! for each.
//!
//! ## Memory layout (normative)
//!
//! `[C, N]` **channel-major**: channel `c` occupies `cols[c*N .. (c+1)*N]`,
//! contiguous. Identical to ADR-0184 D1, and for the same reason — contiguity is
//! what lets `chunks_mut(n)` hand disjoint output slices to workers with no
//! synchronisation. Python callers use `[N, C]`; the transpose is dissolved into
//! the mandatory GIL-boundary copy.
//!
//! ## 0-ULP
//!
//! Every channel runs the same single-channel `apply_into` sequence at the same
//! `tau`, and there is no cross-channel reduction, so the batched result is
//! bit-identical to C sequential solves (ADR-0184 D5 carried over). Batching is
//! a throughput device, not an accuracy one.
extern crate alloc;
use alloc::vec;
use crate::{
chernoff::ChernoffFunction,
error::SemiflowError,
float::{from_f64, SemiflowFloat},
grid::Grid1D,
grid_fn::GridFn1D,
scratch::ScratchPool,
};
/// Validate the `[C, N]` layout and return the channel count.
///
/// # Errors
/// `DomainViolation` if `n == 0`, the buffers differ in length, or the length is
/// not divisible by `n`.
fn validate_layout<F: SemiflowFloat>(
src: &[F],
dst: &[F],
n: usize,
) -> Result<usize, SemiflowError> {
if n == 0 {
return Err(SemiflowError::DomainViolation {
what: "evolve_batched_1d: grid.n == 0",
value: 0.0,
});
}
if src.len() != dst.len() {
#[allow(clippy::cast_precision_loss)]
return Err(SemiflowError::DomainViolation {
what: "evolve_batched_1d: src_cols.len() != dst_cols.len()",
value: src.len() as f64,
});
}
if src.len() % n != 0 {
#[allow(clippy::cast_precision_loss)]
return Err(SemiflowError::DomainViolation {
what: "evolve_batched_1d: src_cols.len() not divisible by grid.n",
value: src.len() as f64,
});
}
Ok(src.len() / n)
}
/// Ping-pong one channel through `n_steps` applications, writing into `dst`.
#[allow(clippy::too_many_arguments)]
fn evolve_channel<C, F>(
func: &C,
tau: F,
n_steps: usize,
src: &[F],
dst: &mut [F],
buf_a: &mut GridFn1D<F>,
buf_b: &mut GridFn1D<F>,
scratch: &mut ScratchPool<F>,
) -> Result<(), SemiflowError>
where
C: ChernoffFunction<F, S = GridFn1D<F>>,
F: SemiflowFloat,
{
buf_a.values.copy_from_slice(src);
let mut src_is_a = true;
for _ in 0..n_steps {
if src_is_a {
func.apply_into(tau, buf_a, buf_b, scratch)?;
} else {
func.apply_into(tau, buf_b, buf_a, scratch)?;
}
src_is_a = !src_is_a;
}
let result: &GridFn1D<F> = if src_is_a { buf_a } else { buf_b };
dst.copy_from_slice(&result.values);
Ok(())
}
/// Evolve `C` channels of a 1-D grid kernel in one call (serial build).
///
/// `src_cols` / `dst_cols` are `[C, N]` channel-major (see the module note).
/// `n_steps == 0` copies `src` to `dst` unchanged.
///
/// # Errors
/// `DomainViolation` on a layout inconsistency; propagates kernel errors.
#[cfg(not(feature = "parallel"))]
pub fn evolve_batched_1d<C, F>(
func: &C,
grid: Grid1D<F>,
t_final: F,
n_steps: usize,
src_cols: &[F],
dst_cols: &mut [F],
) -> Result<(), SemiflowError>
where
C: ChernoffFunction<F, S = GridFn1D<F>>,
F: SemiflowFloat,
{
serial_evolve(func, grid, t_final, n_steps, src_cols, dst_cols)
}
/// Evolve `C` channels of a 1-D grid kernel in one call (parallel build).
///
/// Above [`MIN_CHANNELS_PARALLEL`] channels the work is split across threads,
/// one worker per channel, each with its own `ScratchPool` and ping-pong pair.
/// Bit-identical to the serial path (no cross-channel reduction).
///
/// # Errors
/// `DomainViolation` on a layout inconsistency; propagates kernel errors.
#[cfg(feature = "parallel")]
pub fn evolve_batched_1d<C, F>(
func: &C,
grid: Grid1D<F>,
t_final: F,
n_steps: usize,
src_cols: &[F],
dst_cols: &mut [F],
) -> Result<(), SemiflowError>
where
C: ChernoffFunction<F, S = GridFn1D<F>> + Sync,
F: SemiflowFloat,
{
let n_cols = validate_layout::<F>(src_cols, dst_cols, grid.n)?;
if n_cols == 0 {
return Ok(());
}
if n_steps == 0 {
dst_cols.copy_from_slice(src_cols);
return Ok(());
}
if n_cols >= MIN_CHANNELS_PARALLEL && !node_parallelism_engages(grid.n) {
#[allow(clippy::cast_precision_loss)]
let tau = t_final / from_f64::<F>(n_steps as f64);
return par_evolve(func, grid, tau, n_steps, src_cols, dst_cols);
}
serial_evolve(func, grid, t_final, n_steps, src_cols, dst_cols)
}
/// Shared serial driver (also the fallback for the parallel build).
fn serial_evolve<C, F>(
func: &C,
grid: Grid1D<F>,
t_final: F,
n_steps: usize,
src_cols: &[F],
dst_cols: &mut [F],
) -> Result<(), SemiflowError>
where
C: ChernoffFunction<F, S = GridFn1D<F>>,
F: SemiflowFloat,
{
let n = grid.n;
let n_cols = validate_layout::<F>(src_cols, dst_cols, n)?;
if n_cols == 0 {
return Ok(());
}
if n_steps == 0 {
dst_cols.copy_from_slice(src_cols);
return Ok(());
}
#[allow(clippy::cast_precision_loss)]
let tau = t_final / from_f64::<F>(n_steps as f64);
let mut scratch = ScratchPool::<F>::new();
let mut buf_a = GridFn1D::<F>::new_generic(grid, vec![F::zero(); n])?;
let mut buf_b = GridFn1D::<F>::new_generic(grid, vec![F::zero(); n])?;
for c in 0..n_cols {
let src_c = &src_cols[c * n..(c + 1) * n];
let dst_c = &mut dst_cols[c * n..(c + 1) * n];
evolve_channel(
func,
tau,
n_steps,
src_c,
dst_c,
&mut buf_a,
&mut buf_b,
&mut scratch,
)?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Parallel path
// ---------------------------------------------------------------------------
/// Minimum channel count before channel-parallelism engages.
#[cfg(feature = "parallel")]
pub const MIN_CHANNELS_PARALLEL: usize = 2;
/// Whether `parallel1d` would already be threading over nodes at this grid size.
///
/// Channel-parallel and node-parallel are made **mutually exclusive** rather
/// than nested. `parallel1d::resolve_threads_1d` returns 1 below
/// `2 × min_points_per_thread()`, so the two regimes are exactly complementary:
/// no `(C, N)` engages both, and none is left with neither axis available.
/// Large-N is already saturated node-wise; small-N is precisely where node
/// parallelism declines and channels are the only axis left — which is also the
/// regime the motivating workloads live in (`n = 513…801`).
#[cfg(feature = "parallel")]
fn node_parallelism_engages(n: usize) -> bool {
n >= 2 * crate::parallel1d::min_points_per_thread()
}
/// One `std::thread::scope` worker per channel.
#[cfg(feature = "parallel")]
fn par_evolve<C, F>(
func: &C,
grid: Grid1D<F>,
tau: F,
n_steps: usize,
src_cols: &[F],
dst_cols: &mut [F],
) -> Result<(), SemiflowError>
where
C: ChernoffFunction<F, S = GridFn1D<F>> + Sync,
F: SemiflowFloat,
{
use alloc::sync::Arc;
use std::sync::Mutex;
let n = grid.n;
let err: Arc<Mutex<Option<SemiflowError>>> = Arc::new(Mutex::new(None));
std::thread::scope(|s| {
for (c, dst_c) in dst_cols.chunks_mut(n).enumerate() {
let src_c = &src_cols[c * n..(c + 1) * n];
let err_arc = Arc::clone(&err);
s.spawn(move || {
if let Err(e) = run_worker(func, grid, tau, n_steps, src_c, dst_c) {
store_err(&err_arc, e);
}
});
}
});
take_err(&err)
}
/// One worker's whole job: own buffers, own pool, one channel.
#[cfg(feature = "parallel")]
fn run_worker<C, F>(
func: &C,
grid: Grid1D<F>,
tau: F,
n_steps: usize,
src_c: &[F],
dst_c: &mut [F],
) -> Result<(), SemiflowError>
where
C: ChernoffFunction<F, S = GridFn1D<F>>,
F: SemiflowFloat,
{
// Belt-and-braces against oversubscription: even if the node-parallel
// threshold is later retuned, this worker will not fan out again.
crate::parallel1d::pin_single_thread_1d();
let n = grid.n;
let mut scratch = ScratchPool::<F>::new();
let mut buf_a = GridFn1D::<F>::new_generic(grid, vec![F::zero(); n])?;
let mut buf_b = GridFn1D::<F>::new_generic(grid, vec![F::zero(); n])?;
evolve_channel(
func,
tau,
n_steps,
src_c,
dst_c,
&mut buf_a,
&mut buf_b,
&mut scratch,
)
}
/// Record the first error reported by any worker.
#[cfg(feature = "parallel")]
fn store_err(slot: &alloc::sync::Arc<std::sync::Mutex<Option<SemiflowError>>>, e: SemiflowError) {
if let Ok(mut guard) = slot.lock() {
if guard.is_none() {
*guard = Some(e);
}
}
}
/// Drain the error slot after the scope joins.
#[cfg(feature = "parallel")]
fn take_err(
slot: &alloc::sync::Arc<std::sync::Mutex<Option<SemiflowError>>>,
) -> Result<(), SemiflowError> {
match slot.lock() {
Ok(mut guard) => guard.take().map_or(Ok(()), Err),
Err(_) => Err(SemiflowError::DomainViolation {
what: "evolve_batched_1d: worker error slot poisoned",
value: 0.0,
}),
}
}