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