Skip to main content

laddu_runtime/
backend.rs

1use laddu_compile::{CompiledModel, ReductionPlan};
2#[cfg(feature = "wgpu")]
3use laddu_data::BatchLayout;
4use laddu_data::data::Dataset;
5use laddu_data::data::EventBatch;
6#[cfg(feature = "wgpu")]
7use laddu_data::data::{CacheStorage, MemoryPolicy, accurate::AccurateF64};
8#[cfg(feature = "wgpu")]
9use laddu_data::schema::Precision as DataPrecision;
10use laddu_expr::parameters::ParamValues;
11#[cfg(feature = "wgpu")]
12use laddu_memory::{MemoryDecision, MemoryFootprint};
13use num::complex::Complex64;
14
15#[cfg(feature = "wgpu")]
16use crate::preparation::{DatasetPreparation, DatasetStatsAccumulator, RuntimePreparationPlan};
17use crate::{
18    CpuBackend, CpuPlan, CpuPreparedDataset, Execution, PreparedDatasetStats, ReductionEvaluation,
19    RuntimeError, RuntimeResult,
20};
21
22/// A compiled model prepared for a concrete execution backend.
23#[derive(Clone, Debug)]
24pub enum PreparedModel {
25    /// A model prepared for CPU execution.
26    Cpu(Box<CpuPlan>),
27    #[cfg(feature = "wgpu")]
28    /// A model prepared for WebGPU execution.
29    Wgpu(WgpuPlan),
30}
31
32/// A dataset prepared for a concrete execution backend.
33#[derive(Clone, Debug)]
34pub enum PreparedDataset {
35    /// A dataset prepared for CPU execution.
36    Cpu(CpuPreparedDataset),
37    #[cfg(feature = "wgpu")]
38    /// A dataset prepared for WebGPU execution.
39    Wgpu(WgpuPreparedDataset),
40}
41
42impl PreparedDataset {
43    /// Returns statistics collected while preparing the dataset.
44    pub fn stats(&self) -> &PreparedDatasetStats {
45        match self {
46            Self::Cpu(dataset) => dataset.stats(),
47            #[cfg(feature = "wgpu")]
48            Self::Wgpu(dataset) => dataset.stats(),
49        }
50    }
51}
52
53impl PreparedModel {
54    /// Returns the event-scalar columns required by this prepared model.
55    ///
56    /// Requirements are deduplicated while retaining compiled graph order.
57    pub fn required_event_scalars(&self) -> &[String] {
58        match self {
59            Self::Cpu(plan) => plan.required_event_scalars(),
60            #[cfg(feature = "wgpu")]
61            Self::Wgpu(plan) => &plan.required_event_scalars,
62        }
63    }
64
65    /// Evaluates the model for every event in a batch.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`RuntimeError`] when parameters or event columns are
70    /// incompatible, evaluation fails, or a matrix solve is singular.
71    pub fn evaluate_batch(
72        &self,
73        params: &ParamValues,
74        batch: &EventBatch,
75    ) -> RuntimeResult<Vec<Complex64>> {
76        match self {
77            Self::Cpu(plan) => plan.evaluate_batch(params, batch),
78            #[cfg(feature = "wgpu")]
79            Self::Wgpu(plan) => plan
80                .kernel
81                .evaluate_batch(&plan.context, params, batch)
82                .map(|values| {
83                    values
84                        .into_iter()
85                        .map(|(re, im)| Complex64::new(re, im))
86                        .collect()
87                })
88                .map_err(wgpu_error),
89        }
90    }
91
92    /// Evaluates a vector-root model and returns one output column per root
93    /// element. CPU supports this query-oriented ABI; scalar WGPU kernels
94    /// retain their existing single-output contract.
95    pub(crate) fn evaluate_batch_outputs(
96        &self,
97        params: &ParamValues,
98        batch: &EventBatch,
99        outputs: &[laddu_expr::ExprId],
100    ) -> RuntimeResult<Vec<Vec<Complex64>>> {
101        match self {
102            Self::Cpu(plan) => plan.evaluate_batch_outputs(params, batch, outputs),
103            #[cfg(feature = "wgpu")]
104            Self::Wgpu(_) => Err(RuntimeError::Wgpu(
105                "multi-output query evaluation is not implemented by the WGPU backend".into(),
106            )),
107        }
108    }
109
110    /// Evaluates the model and its free-parameter gradient for every event in a batch.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`RuntimeError`] when inputs are incompatible, differentiation
115    /// or evaluation fails, or the selected backend lacks event-wise gradients.
116    pub fn evaluate_batch_with_gradient(
117        &self,
118        params: &ParamValues,
119        batch: &EventBatch,
120    ) -> RuntimeResult<Vec<crate::ValueGradient>> {
121        match self {
122            Self::Cpu(plan) => plan.evaluate_batch_with_gradient(params, batch),
123            #[cfg(feature = "wgpu")]
124            Self::Wgpu(_) => Err(RuntimeError::Wgpu(
125                "event-wise model gradients are not implemented by the WGPU backend".into(),
126            )),
127        }
128    }
129
130    /// Prepares a compiled model for the supplied execution context.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`RuntimeError`] when model lowering, differentiation, backend
135    /// initialization, or precision selection fails.
136    pub fn prepare(model: &CompiledModel, execution: &Execution) -> RuntimeResult<Self> {
137        #[cfg(feature = "wgpu")]
138        if let Some(context) = execution.wgpu_context() {
139            return Ok(Self::Wgpu(WgpuPlan {
140                context: context.clone(),
141                preparation_params: model.params().default_values(),
142                kernel: std::sync::Arc::new(
143                    laddu_wgpu::WgpuScalarKernel::compile(context, model).map_err(wgpu_error)?,
144                ),
145                required_event_scalars: crate::required_event_scalars(model),
146            }));
147        }
148        Ok(Self::Cpu(Box::new(
149            CpuBackend.prepare_for_execution(model, execution)?,
150        )))
151    }
152
153    /// Prepares a dataset for repeated evaluation with this model.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`RuntimeError`] when the dataset cannot be read or cached, its
158    /// schema is incompatible, or backend preparation fails.
159    pub fn prepare_dataset(
160        &self,
161        execution: &Execution,
162        dataset: &Dataset,
163    ) -> RuntimeResult<PreparedDataset> {
164        match self {
165            Self::Cpu(plan) => Ok(PreparedDataset::Cpu(
166                plan.prepare_dataset(execution, dataset)?,
167            )),
168            #[cfg(feature = "wgpu")]
169            Self::Wgpu(plan) => plan
170                .prepare_dataset(execution, dataset)
171                .map(PreparedDataset::Wgpu),
172        }
173    }
174
175    /// Evaluates every event in a prepared dataset while preserving source order.
176    ///
177    /// Backends with a prepared event adapter reuse their retained or streaming
178    /// prepared blocks. Other backends evaluate the supplied source through the
179    /// already-selected backend; this operation never substitutes a backend.
180    ///
181    /// # Errors
182    ///
183    /// Returns [`RuntimeError`] when model and dataset backends differ, source
184    /// streaming fails, or event evaluation fails.
185    pub fn evaluate_prepared(
186        &self,
187        execution: &Execution,
188        params: &ParamValues,
189        dataset: &PreparedDataset,
190        source: &Dataset,
191    ) -> RuntimeResult<Vec<Complex64>> {
192        let mut output =
193            self.evaluate_prepared_many(execution, std::slice::from_ref(params), dataset, source)?;
194        Ok(output.pop().unwrap_or_default())
195    }
196
197    /// Evaluates multiple parameter sets while each prepared event block is active.
198    ///
199    /// Output rows retain parameter-set order and each row retains source-event order.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`RuntimeError`] when model and dataset backends differ, source
204    /// streaming fails, or event evaluation fails.
205    pub fn evaluate_prepared_many(
206        &self,
207        execution: &Execution,
208        params: &[ParamValues],
209        dataset: &PreparedDataset,
210        source: &Dataset,
211    ) -> RuntimeResult<Vec<Vec<Complex64>>> {
212        #[cfg(not(feature = "wgpu"))]
213        let _ = source;
214        #[allow(unreachable_patterns)]
215        match (self, dataset) {
216            (Self::Cpu(plan), PreparedDataset::Cpu(dataset)) => {
217                plan.evaluate_prepared_dataset_many(execution, params, dataset)
218            }
219            #[cfg(feature = "wgpu")]
220            (Self::Wgpu(_), PreparedDataset::Wgpu(_)) => {
221                evaluate_source_many(self, execution, params, source, None)
222                    .map(|(values, _)| values)
223            }
224            _ => Err(RuntimeError::InvalidShape {
225                index: 0,
226                message: "prepared model and dataset use different backends".into(),
227            }),
228        }
229    }
230
231    /// Visits one bounded block of prepared values at a time for several
232    /// parameter sets without retaining full-dataset value rows.
233    #[doc(hidden)]
234    pub fn visit_prepared_many<F>(
235        &self,
236        execution: &Execution,
237        parameter_sets: &[(&ParamValues, &str)],
238        dataset: &PreparedDataset,
239        source: &Dataset,
240        reduction: Option<ReductionPlan>,
241        consume: F,
242    ) -> RuntimeResult<Vec<f64>>
243    where
244        F: FnMut(usize, usize, &[Complex64]) -> RuntimeResult<()>,
245    {
246        #[cfg(not(feature = "wgpu"))]
247        let _ = source;
248        #[allow(unreachable_patterns)]
249        match (self, dataset) {
250            (Self::Cpu(plan), PreparedDataset::Cpu(dataset)) => plan.visit_prepared_dataset_many(
251                execution,
252                parameter_sets,
253                dataset,
254                reduction,
255                false,
256                consume,
257            ),
258            #[cfg(feature = "wgpu")]
259            (Self::Wgpu(_), PreparedDataset::Wgpu(_)) => {
260                visit_source_many(self, execution, parameter_sets, source, reduction, consume)
261            }
262            _ => Err(RuntimeError::InvalidShape {
263                index: 0,
264                message: "prepared model and dataset use different backends".into(),
265            }),
266        }
267    }
268
269    /// Visits prepared CPU values on the execution-owned thread pool.
270    #[doc(hidden)]
271    pub fn visit_prepared_many_parallel<F>(
272        &self,
273        execution: &Execution,
274        parameter_sets: &[(&ParamValues, &str)],
275        dataset: &PreparedDataset,
276        source: &Dataset,
277        reduction: Option<ReductionPlan>,
278        consume: F,
279    ) -> RuntimeResult<Vec<f64>>
280    where
281        F: FnMut(usize, usize, &[Complex64]) -> RuntimeResult<()> + Send,
282    {
283        #[cfg(not(feature = "wgpu"))]
284        let _ = source;
285        #[allow(unreachable_patterns)]
286        match (self, dataset) {
287            (Self::Cpu(plan), PreparedDataset::Cpu(dataset)) => execution.install(|| {
288                plan.visit_prepared_dataset_many(
289                    execution,
290                    parameter_sets,
291                    dataset,
292                    reduction,
293                    true,
294                    consume,
295                )
296            }),
297            #[cfg(feature = "wgpu")]
298            (Self::Wgpu(_), PreparedDataset::Wgpu(_)) => {
299                visit_source_many(self, execution, parameter_sets, source, reduction, consume)
300            }
301            _ => Err(RuntimeError::InvalidShape {
302                index: 0,
303                message: "prepared model and dataset use different backends".into(),
304            }),
305        }
306    }
307
308    /// Evaluates and reduces multiple parameter sets while each prepared block is active.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`RuntimeError`] when model and dataset backends differ, source
313    /// streaming fails, event evaluation fails, or the reduction rejects a value.
314    pub fn evaluate_prepared_many_with_reduction(
315        &self,
316        execution: &Execution,
317        params: &[ParamValues],
318        dataset: &PreparedDataset,
319        source: &Dataset,
320        reduction: ReductionPlan,
321    ) -> RuntimeResult<(Vec<Vec<Complex64>>, Vec<f64>)> {
322        #[cfg(not(feature = "wgpu"))]
323        let _ = source;
324        #[allow(unreachable_patterns)]
325        match (self, dataset) {
326            (Self::Cpu(plan), PreparedDataset::Cpu(dataset)) => plan
327                .evaluate_prepared_dataset_many_with_reduction(
328                    execution, params, dataset, reduction,
329                ),
330            #[cfg(feature = "wgpu")]
331            (Self::Wgpu(_), PreparedDataset::Wgpu(_)) => {
332                let (values, sums) =
333                    evaluate_source_many(self, execution, params, source, Some(reduction))?;
334                Ok((values, sums.unwrap_or_default()))
335            }
336            _ => Err(RuntimeError::InvalidShape {
337                index: 0,
338                message: "prepared model and dataset use different backends".into(),
339            }),
340        }
341    }
342
343    /// Executes a weighted scalar reduction over a prepared dataset.
344    ///
345    /// # Errors
346    ///
347    /// Returns [`RuntimeError`] when model and dataset backends differ, inputs
348    /// are incompatible, evaluation fails, or the reduction domain is invalid.
349    pub fn reduce(
350        &self,
351        execution: &Execution,
352        params: &ParamValues,
353        dataset: &PreparedDataset,
354        reduction: ReductionPlan,
355    ) -> RuntimeResult<f64> {
356        #[allow(unreachable_patterns)]
357        match (self, dataset) {
358            (Self::Cpu(plan), PreparedDataset::Cpu(dataset)) => {
359                plan.reduce(execution, params, dataset, reduction)
360            }
361            #[cfg(feature = "wgpu")]
362            (Self::Wgpu(plan), PreparedDataset::Wgpu(dataset)) => {
363                plan.reduce(execution, params, dataset, reduction)
364            }
365            _ => Err(RuntimeError::InvalidShape {
366                index: 0,
367                message: "prepared model and dataset use different backends".into(),
368            }),
369        }
370    }
371
372    /// Executes a weighted reduction and computes its free-parameter gradient.
373    ///
374    /// # Errors
375    ///
376    /// Returns [`RuntimeError`] when model and dataset backends differ,
377    /// differentiation or evaluation fails, or the reduction domain is invalid.
378    pub fn reduce_with_gradient(
379        &self,
380        execution: &Execution,
381        params: &ParamValues,
382        dataset: &PreparedDataset,
383        reduction: ReductionPlan,
384    ) -> RuntimeResult<ReductionEvaluation> {
385        #[allow(unreachable_patterns)]
386        match (self, dataset) {
387            (Self::Cpu(plan), PreparedDataset::Cpu(dataset)) => {
388                plan.reduce_with_gradient(execution, params, dataset, reduction)
389            }
390            #[cfg(feature = "wgpu")]
391            (Self::Wgpu(plan), PreparedDataset::Wgpu(dataset)) => {
392                plan.reduce_with_gradient(execution, params, dataset, reduction)
393            }
394            _ => Err(RuntimeError::InvalidShape {
395                index: 0,
396                message: "prepared model and dataset use different backends".into(),
397            }),
398        }
399    }
400}
401
402#[cfg(feature = "wgpu")]
403type PreparedValuesWithSums = (Vec<Vec<Complex64>>, Option<Vec<f64>>);
404
405#[cfg(feature = "wgpu")]
406fn evaluate_source_many(
407    model: &PreparedModel,
408    execution: &Execution,
409    params: &[ParamValues],
410    source: &Dataset,
411    reduction: Option<ReductionPlan>,
412) -> RuntimeResult<PreparedValuesWithSums> {
413    let local = (|| {
414        let mut output = params.iter().map(|_| Vec::new()).collect::<Vec<_>>();
415        let mut sums = reduction.map(|_| {
416            params
417                .iter()
418                .map(|_| AccurateF64::zero())
419                .collect::<Vec<_>>()
420        });
421        for batch in source
422            .batches()
423            .map_err(|error| RuntimeError::Data(error.to_string()))?
424        {
425            let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
426            for (index, (parameters, values)) in params.iter().zip(&mut output).enumerate() {
427                let batch_values = model.evaluate_batch(parameters, &batch)?;
428                if let (Some(reduction), Some(sums)) = (reduction, sums.as_mut()) {
429                    for (row, value) in batch_values.iter().enumerate() {
430                        sums[index].push(batch.weights_at(row) * reduction.apply(*value)?.value());
431                    }
432                }
433                values.extend(batch_values);
434            }
435        }
436        Ok((
437            output,
438            sums.map(|sums| sums.into_iter().map(AccurateF64::finish).collect()),
439        ))
440    })();
441    if !execution.all_succeeded(local.is_ok()) {
442        return local.and(Err(RuntimeError::DistributedPeerFailure));
443    }
444    let (output, sums) = local?;
445    Ok((
446        output,
447        sums.map(|sums: Vec<f64>| sums.into_iter().map(|sum| execution.sum_f64(sum)).collect()),
448    ))
449}
450
451#[cfg(feature = "wgpu")]
452fn visit_source_many<F>(
453    model: &PreparedModel,
454    execution: &Execution,
455    parameter_sets: &[(&ParamValues, &str)],
456    source: &Dataset,
457    reduction: Option<ReductionPlan>,
458    mut consume: F,
459) -> RuntimeResult<Vec<f64>>
460where
461    F: FnMut(usize, usize, &[Complex64]) -> RuntimeResult<()>,
462{
463    let local = (|| {
464        let mut offset = 0;
465        let mut sums = reduction.map(|_| {
466            parameter_sets
467                .iter()
468                .map(|_| AccurateF64::zero())
469                .collect::<Vec<_>>()
470        });
471        for batch in source
472            .batches()
473            .map_err(|error| RuntimeError::Data(error.to_string()))?
474        {
475            let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
476            for (index, &(parameters, context)) in parameter_sets.iter().enumerate() {
477                let values = model.evaluate_batch(parameters, &batch).map_err(|error| {
478                    RuntimeError::Parameter(format!("{context} evaluation failed: {error}"))
479                })?;
480                if let (Some(reduction), Some(sums)) = (reduction, sums.as_mut()) {
481                    for (row, value) in values.iter().enumerate() {
482                        let reduced = reduction.apply(*value).map_err(|error| {
483                            RuntimeError::Parameter(format!("{context} reduction failed: {error}"))
484                        })?;
485                        sums[index].push(batch.weights_at(row) * reduced.value());
486                    }
487                }
488                consume(offset, index, &values)?;
489            }
490            offset += batch.len();
491        }
492        Ok(sums
493            .unwrap_or_default()
494            .into_iter()
495            .map(AccurateF64::finish)
496            .collect::<Vec<_>>())
497    })();
498    if !execution.all_succeeded(local.is_ok()) {
499        return local.and(Err(RuntimeError::DistributedPeerFailure));
500    }
501    Ok(local?
502        .into_iter()
503        .map(|sum| execution.sum_f64(sum))
504        .collect())
505}
506
507#[cfg(feature = "wgpu")]
508/// A compiled model prepared for WebGPU execution.
509#[derive(Clone)]
510pub struct WgpuPlan {
511    context: std::sync::Arc<laddu_wgpu::WgpuContext>,
512    preparation_params: ParamValues,
513    kernel: std::sync::Arc<laddu_wgpu::WgpuScalarKernel>,
514    required_event_scalars: Vec<String>,
515}
516
517#[cfg(feature = "wgpu")]
518#[derive(Clone, Debug)]
519struct WgpuDatasetPlan {
520    read_plan: laddu_data::io::ReadPlan,
521    preparation_plan: RuntimePreparationPlan,
522    device_decision: MemoryDecision,
523}
524
525#[cfg(feature = "wgpu")]
526impl WgpuDatasetPlan {
527    fn resolve(
528        read_plan: laddu_data::io::ReadPlan,
529        memory_policy: MemoryPolicy,
530        local_event_limit: usize,
531        host_footprint: MemoryFootprint,
532        prepared_footprint: MemoryFootprint,
533        host_available: u64,
534        device_available: Option<u64>,
535    ) -> RuntimeResult<Self> {
536        let mut preparation_plan = RuntimePreparationPlan::new(read_plan, local_event_limit);
537        let host_decision = preparation_plan.fit_staging(
538            "WGPU host staging",
539            host_footprint,
540            host_available,
541            "bounded host staging",
542        )?;
543        let host_chunks = local_event_limit
544            .saturating_add(host_decision.chunk_events.saturating_sub(1))
545            / host_decision.chunk_events.max(1);
546        let resident_footprint = MemoryFootprint::fixed(prepared_footprint.fixed_bytes)
547            .checked_scale_usize(host_chunks)
548            .and_then(|fixed| {
549                fixed.checked_add(MemoryFootprint::per_event(
550                    prepared_footprint.bytes_per_event,
551                ))
552            })
553            .map_err(|error| RuntimeError::Data(format!("GPU working-set overflow: {error}")))?;
554        let resident_peak = resident_footprint.peak_bytes(local_event_limit);
555        let device_available = device_available
556            .ok_or_else(|| RuntimeError::Wgpu("GPU execution has no device memory pool".into()))?;
557        preparation_plan.select_storage(
558            memory_policy,
559            "device",
560            resident_peak <= device_available,
561            resident_peak,
562            device_available,
563        )?;
564        let device_decision = if preparation_plan.storage() == CacheStorage::Resident {
565            preparation_plan.fit_resident(
566                "WGPU prepared dataset",
567                resident_footprint,
568                device_available,
569                "resident",
570                host_decision.chunk_events,
571            )?
572        } else {
573            preparation_plan.fit_staging(
574                "WGPU prepared dataset",
575                prepared_footprint,
576                device_available,
577                "streaming",
578            )?
579        };
580        let chunk_events = device_decision
581            .chunk_events
582            .min(host_decision.chunk_events)
583            .max(1);
584        preparation_plan.clamp_read_plan(read_plan.chunk_size, chunk_events);
585        Ok(Self {
586            read_plan: preparation_plan.read_plan(),
587            preparation_plan,
588            device_decision,
589        })
590    }
591
592    fn reserve_storage(
593        &mut self,
594        pool: Option<&crate::MemoryPool>,
595    ) -> RuntimeResult<Option<crate::MemoryLease>> {
596        self.preparation_plan.reserve_storage(pool, || {
597            RuntimeError::Wgpu("GPU execution has no device memory pool".into())
598        })?;
599        Ok(self.preparation_plan.take_memory_lease())
600    }
601}
602
603#[cfg(feature = "wgpu")]
604impl std::fmt::Debug for WgpuPlan {
605    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606        formatter
607            .debug_struct("WgpuPlan")
608            .field("adapter", &self.context.info().name)
609            .finish_non_exhaustive()
610    }
611}
612
613#[cfg(feature = "wgpu")]
614/// Dataset storage prepared for WebGPU evaluation.
615#[derive(Clone)]
616pub enum WgpuPreparedDataset {
617    /// GPU-resident prepared batches.
618    Resident {
619        /// Prepared GPU batches.
620        batches: std::sync::Arc<[laddu_wgpu::WgpuPreparedBatch]>,
621        /// Preparation statistics.
622        stats: PreparedDatasetStats,
623        /// Persistent device-memory reservation.
624        memory_lease: crate::MemoryLease,
625    },
626    /// Source data streamed and prepared one batch at a time.
627    Streaming {
628        /// Source dataset.
629        dataset: Dataset,
630        /// Read plan used on each pass.
631        read_plan: laddu_data::io::ReadPlan,
632        /// Reusable prepared-batch workspace.
633        workspace: std::sync::Arc<std::sync::Mutex<Option<laddu_wgpu::WgpuPreparedBatch>>>,
634        /// Preparation statistics.
635        stats: PreparedDatasetStats,
636        /// Peak transient device bytes reserved during reductions.
637        transient_bytes: u64,
638    },
639}
640
641#[cfg(feature = "wgpu")]
642impl std::fmt::Debug for WgpuPreparedDataset {
643    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
644        formatter
645            .debug_struct("WgpuPreparedDataset")
646            .field("stats", self.stats())
647            .finish_non_exhaustive()
648    }
649}
650
651#[cfg(feature = "wgpu")]
652impl WgpuPreparedDataset {
653    /// Returns statistics collected while preparing the dataset.
654    pub fn stats(&self) -> &PreparedDatasetStats {
655        match self {
656            Self::Resident { stats, .. } | Self::Streaming { stats, .. } => stats,
657        }
658    }
659
660    fn try_for_each_prepared_batch<F>(
661        &self,
662        execution: &Execution,
663        context: &laddu_wgpu::WgpuContext,
664        kernel: &laddu_wgpu::WgpuScalarKernel,
665        preparation_params: &ParamValues,
666        mut consume: F,
667    ) -> RuntimeResult<()>
668    where
669        F: FnMut(&laddu_wgpu::WgpuPreparedBatch) -> RuntimeResult<()>,
670    {
671        match self {
672            Self::Resident { batches, .. } => {
673                for batch in batches.iter() {
674                    consume(batch)?;
675                }
676            }
677            Self::Streaming {
678                dataset,
679                read_plan,
680                workspace,
681                transient_bytes,
682                ..
683            } => {
684                let _memory = execution
685                    .device_memory()
686                    .ok_or_else(|| {
687                        RuntimeError::Wgpu("GPU execution has no device memory pool".into())
688                    })?
689                    .reserve(*transient_bytes)?;
690                let mut workspace = workspace.lock().map_err(|_| {
691                    RuntimeError::Wgpu("streaming workspace lock is poisoned".into())
692                })?;
693                for batch in dataset
694                    .stream_with_plan(*read_plan)
695                    .map_err(|error| RuntimeError::Data(error.to_string()))?
696                {
697                    let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
698                    if let Some(prepared) = workspace.as_mut() {
699                        if !kernel
700                            .refresh_batch(context, preparation_params, &batch, prepared)
701                            .map_err(wgpu_error)?
702                        {
703                            *prepared = kernel
704                                .prepare_batch(context, preparation_params, &batch)
705                                .map_err(wgpu_error)?;
706                        }
707                    } else {
708                        *workspace = Some(
709                            kernel
710                                .prepare_batch(context, preparation_params, &batch)
711                                .map_err(wgpu_error)?,
712                        );
713                    }
714                    consume(
715                        workspace
716                            .as_ref()
717                            .expect("streaming workspace was initialized"),
718                    )?;
719                }
720            }
721        }
722        Ok(())
723    }
724}
725
726#[cfg(feature = "wgpu")]
727impl WgpuPlan {
728    fn prepare_dataset(
729        &self,
730        execution: &Execution,
731        dataset: &Dataset,
732    ) -> RuntimeResult<WgpuPreparedDataset> {
733        let preparation = DatasetPreparation::new(execution, dataset);
734        let preparation_plan = preparation.runtime_plan()?;
735        let read_plan = preparation_plan.read_plan();
736        let local_event_limit = preparation_plan.event_limit();
737        let prepared_footprint = self
738            .kernel
739            .prepared_memory_footprint(&self.preparation_params)
740            .map_err(|error| RuntimeError::Data(format!("GPU working-set overflow: {error}")))?;
741        let schema = dataset
742            .schema()
743            .map_err(|error| RuntimeError::Data(error.to_string()))?;
744        let host_footprint = BatchLayout::from_schema(&schema)
745            .schema_working_set(DataPrecision::F64, 2)
746            .map_err(|error| RuntimeError::Data(format!("host working-set overflow: {error}")))?;
747        let mut plan = WgpuDatasetPlan::resolve(
748            read_plan,
749            dataset.memory_policy(),
750            local_event_limit,
751            host_footprint,
752            prepared_footprint,
753            execution.host_memory().remaining(),
754            execution.device_memory().map(|pool| pool.remaining()),
755        )?;
756        let memory_lease = plan.reserve_storage(execution.device_memory())?;
757        for decision in plan.preparation_plan.take_decisions().into_iter().rev() {
758            execution.record_memory_decision(decision);
759        }
760        let local = (|| {
761            let mut batches = Vec::new();
762            let mut stats = DatasetStatsAccumulator::new();
763            for batch in dataset
764                .stream_with_plan(plan.read_plan)
765                .map_err(|error| RuntimeError::Data(error.to_string()))?
766            {
767                let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
768                stats.observe(&batch);
769                if plan.preparation_plan.storage() == CacheStorage::Resident {
770                    batches.push(
771                        self.kernel
772                            .prepare_batch(&self.context, &self.preparation_params, &batch)
773                            .map_err(wgpu_error)?,
774                    );
775                }
776            }
777            Ok::<_, RuntimeError>((batches, stats.finish()))
778        })();
779        let (batches, local_stats) = preparation.coordinate(local)?;
780        let resident_bytes = batches
781            .iter()
782            .map(laddu_wgpu::WgpuPreparedBatch::resident_bytes)
783            .sum();
784        let stats =
785            preparation.finish_stats(local_stats, resident_bytes, plan.preparation_plan.storage());
786        Ok(match plan.preparation_plan.storage() {
787            CacheStorage::Resident => WgpuPreparedDataset::Resident {
788                batches: batches.into(),
789                stats,
790                memory_lease: memory_lease.ok_or_else(|| {
791                    RuntimeError::Wgpu("resident GPU dataset did not reserve device memory".into())
792                })?,
793            },
794            CacheStorage::Streaming => WgpuPreparedDataset::Streaming {
795                dataset: dataset.clone(),
796                read_plan: plan.read_plan,
797                workspace: Default::default(),
798                stats,
799                transient_bytes: plan.device_decision.estimated_peak_bytes,
800            },
801        })
802    }
803
804    fn reduce(
805        &self,
806        execution: &Execution,
807        params: &ParamValues,
808        dataset: &WgpuPreparedDataset,
809        reduction: ReductionPlan,
810    ) -> RuntimeResult<f64> {
811        let mut total = AccurateF64::zero();
812        dataset.try_for_each_prepared_batch(
813            execution,
814            &self.context,
815            &self.kernel,
816            &self.preparation_params,
817            |batch| {
818                total.push(
819                    self.kernel
820                        .reduce_prepared_batch(&self.context, params, batch, reduction)
821                        .map_err(wgpu_error)?,
822                );
823                Ok(())
824            },
825        )?;
826        Ok(execution.sum_f64(total.finish()))
827    }
828
829    fn reduce_with_gradient(
830        &self,
831        execution: &Execution,
832        params: &ParamValues,
833        dataset: &WgpuPreparedDataset,
834        reduction: ReductionPlan,
835    ) -> RuntimeResult<ReductionEvaluation> {
836        let mut total = AccurateF64::zero();
837        let mut gradient = (0..params.layout().n_free())
838            .map(|_| AccurateF64::zero())
839            .collect::<Vec<_>>();
840        let mut consume = |batch: &laddu_wgpu::WgpuPreparedBatch| -> RuntimeResult<()> {
841            let (value, values) = self
842                .kernel
843                .reduce_prepared_batch_with_gradient(&self.context, params, batch, reduction)
844                .map_err(wgpu_error)?;
845            total.push(value);
846            for (sum, value) in gradient.iter_mut().zip(values) {
847                sum.push(value);
848            }
849            Ok(())
850        };
851        dataset.try_for_each_prepared_batch(
852            execution,
853            &self.context,
854            &self.kernel,
855            &self.preparation_params,
856            &mut consume,
857        )?;
858        let gradient = gradient
859            .into_iter()
860            .map(|sum| execution.sum_f64(sum.finish()))
861            .collect();
862        Ok(ReductionEvaluation::new(
863            execution.sum_f64(total.finish()),
864            gradient,
865        ))
866    }
867}
868
869#[cfg(feature = "wgpu")]
870fn wgpu_error(error: laddu_wgpu::WgpuError) -> RuntimeError {
871    RuntimeError::Wgpu(error.to_string())
872}
873
874#[cfg(test)]
875mod prepared_evaluation_tests {
876    use std::{cell::RefCell, rc::Rc, sync::Arc};
877
878    use laddu_compile::{CompiledModel, ReductionPlan};
879    use laddu_data::{
880        data::{Dataset, EventBatch, OwnedEvent},
881        schema::Schema,
882    };
883    use laddu_expr::{event_scalar, parameter};
884    use num::complex::Complex64;
885
886    use super::PreparedModel;
887    use crate::{CpuOptions, Device, Execution, ExecutionOptions, JitPolicy, ThreadPolicy};
888
889    fn dataset(streaming: bool) -> Dataset {
890        let schema = Arc::new(
891            Schema::new(std::iter::empty::<&str>(), ["x"], false)
892                .expect("test schema should be valid"),
893        );
894        let first_batch = EventBatch::from_events(
895            schema.clone(),
896            [
897                OwnedEvent::new(vec![], vec![1.0]),
898                OwnedEvent::new(vec![], vec![2.0]),
899            ],
900        )
901        .expect("first test batch should be valid");
902        let second_batch = EventBatch::from_events(schema, [OwnedEvent::new(vec![], vec![3.0])])
903            .expect("second test batch should be valid");
904        let dataset = Dataset::from_batches(vec![first_batch, second_batch])
905            .expect("test dataset should be valid");
906        if streaming {
907            dataset.streaming()
908        } else {
909            dataset.fastest()
910        }
911    }
912
913    #[test]
914    fn prepared_evaluation_preserves_event_order_for_resident_and_streaming_data() {
915        let model =
916            CompiledModel::from_expr(&(event_scalar("x") + parameter!("offset", initial: 0.5)))
917                .expect("test model should compile");
918        let execution = Execution::default();
919        let prepared_model =
920            PreparedModel::prepare(&model, &execution).expect("model should prepare");
921        let parameters = [
922            model.params().values(&[0.5]).expect("first parameters"),
923            model.params().values(&[1.5]).expect("second parameters"),
924        ];
925        let expected = [
926            vec![
927                Complex64::new(1.5, 0.0),
928                Complex64::new(2.5, 0.0),
929                Complex64::new(3.5, 0.0),
930            ],
931            vec![
932                Complex64::new(2.5, 0.0),
933                Complex64::new(3.5, 0.0),
934                Complex64::new(4.5, 0.0),
935            ],
936        ];
937
938        for streaming in [false, true] {
939            let source = dataset(streaming);
940            let prepared = prepared_model
941                .prepare_dataset(&execution, &source)
942                .expect("dataset should prepare");
943            let actual = prepared_model
944                .evaluate_prepared_many(&execution, &parameters, &prepared, &source)
945                .expect("prepared dataset should evaluate");
946            assert_eq!(actual, expected);
947            let (actual, sums) = prepared_model
948                .evaluate_prepared_many_with_reduction(
949                    &execution,
950                    &parameters,
951                    &prepared,
952                    &source,
953                    ReductionPlan::weighted_positive_real(),
954                )
955                .expect("prepared dataset should evaluate and reduce");
956            assert_eq!(actual, expected);
957            assert_eq!(sums, [7.5, 10.5]);
958            let mut visited = vec![Vec::new(), Vec::new()];
959            let parameter_sets = [
960                (&parameters[0], "central"),
961                (&parameters[1], "ensemble draw 0"),
962            ];
963            let sums = prepared_model
964                .visit_prepared_many(
965                    &execution,
966                    &parameter_sets,
967                    &prepared,
968                    &source,
969                    Some(ReductionPlan::weighted_positive_real()),
970                    |_, parameter_index, values| {
971                        visited[parameter_index].extend_from_slice(values);
972                        Ok(())
973                    },
974                )
975                .expect("prepared blocks should be visited and reduced");
976            assert_eq!(visited, expected);
977            assert_eq!(sums, [7.5, 10.5]);
978        }
979    }
980
981    #[test]
982    fn prepared_block_visitors_run_inside_the_execution_owned_pool() {
983        let model =
984            CompiledModel::from_expr(&event_scalar("x")).expect("test model should compile");
985        let execution = Execution::local(ExecutionOptions {
986            device: Device::Cpu(CpuOptions {
987                threads: ThreadPolicy::Fixed(2),
988                jit: JitPolicy::Disabled,
989            }),
990            ..ExecutionOptions::default()
991        })
992        .expect("fixed-thread execution should build");
993        let prepared_model =
994            PreparedModel::prepare(&model, &execution).expect("model should prepare");
995        let source = dataset(false);
996        let prepared = prepared_model
997            .prepare_dataset(&execution, &source)
998            .expect("dataset should prepare");
999        let parameters = model.params().values(&[]).expect("parameters should build");
1000        let mut visitor_pool_threads = Vec::new();
1001
1002        prepared_model
1003            .visit_prepared_many_parallel(
1004                &execution,
1005                &[(&parameters, "central")],
1006                &prepared,
1007                &source,
1008                None,
1009                |_, _, _| {
1010                    visitor_pool_threads.push(rayon::current_num_threads());
1011                    Ok(())
1012                },
1013            )
1014            .expect("prepared blocks should be visited");
1015
1016        assert!(!visitor_pool_threads.is_empty());
1017        assert!(visitor_pool_threads.iter().all(|&threads| threads == 2));
1018    }
1019
1020    #[test]
1021    fn existing_prepared_visitor_accepts_non_send_callbacks() {
1022        let model =
1023            CompiledModel::from_expr(&event_scalar("x")).expect("test model should compile");
1024        let execution = Execution::default();
1025        let prepared_model =
1026            PreparedModel::prepare(&model, &execution).expect("model should prepare");
1027        let source = dataset(false);
1028        let prepared = prepared_model
1029            .prepare_dataset(&execution, &source)
1030            .expect("dataset should prepare");
1031        let parameters = model.params().values(&[]).expect("parameters should build");
1032        let visited = Rc::new(RefCell::new(Vec::new()));
1033        let captured = Rc::clone(&visited);
1034
1035        prepared_model
1036            .visit_prepared_many(
1037                &execution,
1038                &[(&parameters, "central")],
1039                &prepared,
1040                &source,
1041                None,
1042                move |_, _, values| {
1043                    captured.borrow_mut().extend_from_slice(values);
1044                    Ok(())
1045                },
1046            )
1047            .expect("non-Send visitor should remain supported");
1048
1049        assert_eq!(visited.borrow().len(), 3);
1050    }
1051}
1052
1053#[cfg(all(test, feature = "wgpu"))]
1054mod tests {
1055    use std::sync::Arc;
1056
1057    use laddu_compile::{CompiledModel, ReductionPlan};
1058    use laddu_data::{
1059        data::{Dataset, EventBatch, OwnedEvent},
1060        schema::Schema,
1061    };
1062    use laddu_expr::{complex, event_scalar, parameter};
1063
1064    use super::*;
1065    use crate::{CpuOptions, Device, ExecutionOptions, GpuBackend, GpuOptions, Precision};
1066
1067    #[test]
1068    fn wgpu_dataset_plan_resolves_storage_and_chunk_limits_without_hardware() {
1069        let mut read_plan = laddu_data::io::ReadPlan::serial();
1070        read_plan.chunk_size = Some(3);
1071        let plan = WgpuDatasetPlan::resolve(
1072            read_plan,
1073            MemoryPolicy::Fastest,
1074            100,
1075            MemoryFootprint::new(100, 8),
1076            MemoryFootprint::new(200, 4),
1077            1_000,
1078            Some(10_000),
1079        )
1080        .unwrap();
1081
1082        assert_eq!(plan.preparation_plan.storage(), CacheStorage::Resident);
1083        assert_eq!(plan.read_plan.chunk_size, Some(3));
1084        assert_eq!(plan.device_decision.chunk_events, 100);
1085        assert_eq!(plan.device_decision.estimated_peak_bytes, 600);
1086    }
1087
1088    #[test]
1089    fn wgpu_dataset_plan_falls_back_to_streaming_when_resident_does_not_fit() {
1090        let plan = WgpuDatasetPlan::resolve(
1091            laddu_data::io::ReadPlan::serial(),
1092            MemoryPolicy::Fastest,
1093            100,
1094            MemoryFootprint::new(100, 8),
1095            MemoryFootprint::new(500, 10),
1096            1_000,
1097            Some(1_000),
1098        )
1099        .unwrap();
1100
1101        assert_eq!(plan.preparation_plan.storage(), CacheStorage::Streaming);
1102        assert_eq!(plan.read_plan.chunk_size, Some(50));
1103        assert_eq!(plan.device_decision.chunk_events, 50);
1104        assert_eq!(plan.device_decision.estimated_peak_bytes, 1_000);
1105    }
1106
1107    #[test]
1108    fn wgpu_dataset_plan_reports_host_failure_before_missing_device_pool() {
1109        let error = WgpuDatasetPlan::resolve(
1110            laddu_data::io::ReadPlan::serial(),
1111            MemoryPolicy::Fastest,
1112            1,
1113            MemoryFootprint::new(100, 8),
1114            MemoryFootprint::new(200, 4),
1115            100,
1116            None,
1117        )
1118        .unwrap_err();
1119
1120        assert!(matches!(
1121            error,
1122            RuntimeError::Memory(laddu_memory::MemoryError::BudgetExceeded { resource, .. })
1123                if resource == "WGPU host staging"
1124        ));
1125    }
1126
1127    #[test]
1128    #[ignore = "requires a WGPU-compatible hardware adapter"]
1129    fn wgpu_resident_and_streaming_reductions_match_f32_cpu() {
1130        let scale = laddu_expr::Expr::from(parameter!("scale", initial: 1.25));
1131        let offset = laddu_expr::Expr::from(parameter!("offset", initial: 0.5));
1132        let x = event_scalar("x");
1133        let expression = (x.clone() * scale.clone() + offset.clone()).sin()
1134            + complex(scale, offset).norm_sqr()
1135            + 2.0;
1136        let model = CompiledModel::from_expr(&expression).unwrap();
1137        let params = model.params().default_values();
1138        let schema = Arc::new(Schema::new(std::iter::empty::<&str>(), ["x"], true).unwrap());
1139        let dataset = Dataset::from_batches(vec![
1140            EventBatch::from_events(
1141                schema.clone(),
1142                [
1143                    OwnedEvent::weighted(vec![], vec![0.25], 0.5),
1144                    OwnedEvent::weighted(vec![], vec![0.75], 1.5),
1145                ],
1146            )
1147            .unwrap(),
1148            EventBatch::from_events(schema, [OwnedEvent::weighted(vec![], vec![1.25], 2.0)])
1149                .unwrap(),
1150        ])
1151        .unwrap();
1152        let wgpu_execution = Execution::local(ExecutionOptions {
1153            device: Device::Gpu(GpuOptions {
1154                backend: GpuBackend::Wgpu,
1155                ..GpuOptions::default()
1156            }),
1157            memory: crate::MemoryPlan::host_device(
1158                crate::MemoryBudget::Auto,
1159                crate::MemoryBudget::Bytes(256),
1160            ),
1161            precision: Precision::F32,
1162            ..ExecutionOptions::default()
1163        })
1164        .unwrap();
1165        let cpu_execution = Execution::local(ExecutionOptions {
1166            device: Device::Cpu(CpuOptions::default()),
1167            precision: Precision::F32,
1168            ..ExecutionOptions::default()
1169        })
1170        .unwrap();
1171        let wgpu = PreparedModel::prepare(&model, &wgpu_execution).unwrap();
1172        let cpu = PreparedModel::prepare(&model, &cpu_execution).unwrap();
1173        let resident = wgpu
1174            .prepare_dataset(&wgpu_execution, &dataset.clone().resident())
1175            .unwrap();
1176        let streaming = wgpu
1177            .prepare_dataset(&wgpu_execution, &dataset.clone().streaming())
1178            .unwrap();
1179        let cpu_data = cpu.prepare_dataset(&cpu_execution, &dataset).unwrap();
1180
1181        assert_eq!(resident.stats().storage(), CacheStorage::Resident);
1182        assert_eq!(streaming.stats().storage(), CacheStorage::Streaming);
1183        assert!(resident.stats().resident_bytes() > 0);
1184        assert_eq!(streaming.stats().resident_bytes(), 0);
1185
1186        let cpu_reduction = cpu
1187            .reduce_with_gradient(
1188                &cpu_execution,
1189                &params,
1190                &cpu_data,
1191                ReductionPlan::weighted_real(),
1192            )
1193            .unwrap();
1194        let resident_reduction = wgpu
1195            .reduce_with_gradient(
1196                &wgpu_execution,
1197                &params,
1198                &resident,
1199                ReductionPlan::weighted_real(),
1200            )
1201            .unwrap();
1202        let streaming_reduction = wgpu
1203            .reduce_with_gradient(
1204                &wgpu_execution,
1205                &params,
1206                &streaming,
1207                ReductionPlan::weighted_real(),
1208            )
1209            .unwrap();
1210
1211        for actual in [&resident_reduction, &streaming_reduction] {
1212            assert!((actual.value() - cpu_reduction.value()).abs() <= 1.0e-4);
1213            assert_eq!(actual.gradient().len(), cpu_reduction.gradient().len());
1214            for (actual, expected) in actual.gradient().iter().zip(cpu_reduction.gradient()) {
1215                assert!((actual - expected).abs() <= 1.0e-4);
1216            }
1217        }
1218        assert!((resident_reduction.value() - streaming_reduction.value()).abs() <= 1.0e-6);
1219        for (resident, streaming) in resident_reduction
1220            .gradient()
1221            .iter()
1222            .zip(streaming_reduction.gradient())
1223        {
1224            assert!((resident - streaming).abs() <= 1.0e-6);
1225        }
1226    }
1227}