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::{MemoryFitRequest, MemoryFootprint};
13use num::complex::Complex64;
14
15use crate::{
16 CpuBackend, CpuPlan, CpuPreparedDataset, Execution, PreparedDatasetStats, ReductionEvaluation,
17 RuntimeError, RuntimeResult,
18};
19
20#[derive(Clone, Debug)]
22pub enum PreparedModel {
23 Cpu(Box<CpuPlan>),
25 #[cfg(feature = "wgpu")]
26 Wgpu(WgpuPlan),
28}
29
30#[derive(Clone, Debug)]
32pub enum PreparedDataset {
33 Cpu(CpuPreparedDataset),
35 #[cfg(feature = "wgpu")]
36 Wgpu(WgpuPreparedDataset),
38}
39
40impl PreparedDataset {
41 pub fn stats(&self) -> &PreparedDatasetStats {
43 match self {
44 Self::Cpu(dataset) => dataset.stats(),
45 #[cfg(feature = "wgpu")]
46 Self::Wgpu(dataset) => dataset.stats(),
47 }
48 }
49}
50
51impl PreparedModel {
52 pub fn evaluate_batch(
59 &self,
60 params: &ParamValues,
61 batch: &EventBatch,
62 ) -> RuntimeResult<Vec<Complex64>> {
63 match self {
64 Self::Cpu(plan) => plan.evaluate_batch(params, batch),
65 #[cfg(feature = "wgpu")]
66 Self::Wgpu(plan) => plan
67 .kernel
68 .evaluate_batch(&plan.context, params, batch)
69 .map(|values| {
70 values
71 .into_iter()
72 .map(|(re, im)| Complex64::new(re, im))
73 .collect()
74 })
75 .map_err(wgpu_error),
76 }
77 }
78
79 pub fn evaluate_batch_with_gradient(
86 &self,
87 params: &ParamValues,
88 batch: &EventBatch,
89 ) -> RuntimeResult<Vec<crate::ValueGradient>> {
90 match self {
91 Self::Cpu(plan) => plan.evaluate_batch_with_gradient(params, batch),
92 #[cfg(feature = "wgpu")]
93 Self::Wgpu(_) => Err(RuntimeError::Wgpu(
94 "event-wise model gradients are not implemented by the WGPU backend".into(),
95 )),
96 }
97 }
98
99 pub fn prepare(model: &CompiledModel, execution: &Execution) -> RuntimeResult<Self> {
106 #[cfg(feature = "wgpu")]
107 if let Some(context) = execution.wgpu_context() {
108 return Ok(Self::Wgpu(WgpuPlan {
109 context: context.clone(),
110 preparation_params: model.params().default_values(),
111 kernel: std::sync::Arc::new(
112 laddu_wgpu::WgpuScalarKernel::compile(context, model).map_err(wgpu_error)?,
113 ),
114 }));
115 }
116 Ok(Self::Cpu(Box::new(
117 CpuBackend.prepare_for_execution(model, execution)?,
118 )))
119 }
120
121 pub fn prepare_dataset(
128 &self,
129 execution: &Execution,
130 dataset: &Dataset,
131 ) -> RuntimeResult<PreparedDataset> {
132 match self {
133 Self::Cpu(plan) => Ok(PreparedDataset::Cpu(
134 plan.prepare_dataset(execution, dataset)?,
135 )),
136 #[cfg(feature = "wgpu")]
137 Self::Wgpu(plan) => plan
138 .prepare_dataset(execution, dataset)
139 .map(PreparedDataset::Wgpu),
140 }
141 }
142
143 pub fn reduce(
150 &self,
151 execution: &Execution,
152 params: &ParamValues,
153 dataset: &PreparedDataset,
154 reduction: ReductionPlan,
155 ) -> RuntimeResult<f64> {
156 #[allow(unreachable_patterns)]
157 match (self, dataset) {
158 (Self::Cpu(plan), PreparedDataset::Cpu(dataset)) => {
159 plan.reduce(execution, params, dataset, reduction)
160 }
161 #[cfg(feature = "wgpu")]
162 (Self::Wgpu(plan), PreparedDataset::Wgpu(dataset)) => {
163 plan.reduce(execution, params, dataset, reduction)
164 }
165 _ => Err(RuntimeError::InvalidShape {
166 index: 0,
167 message: "prepared model and dataset use different backends".into(),
168 }),
169 }
170 }
171
172 pub fn reduce_with_gradient(
179 &self,
180 execution: &Execution,
181 params: &ParamValues,
182 dataset: &PreparedDataset,
183 reduction: ReductionPlan,
184 ) -> RuntimeResult<ReductionEvaluation> {
185 #[allow(unreachable_patterns)]
186 match (self, dataset) {
187 (Self::Cpu(plan), PreparedDataset::Cpu(dataset)) => {
188 plan.reduce_with_gradient(execution, params, dataset, reduction)
189 }
190 #[cfg(feature = "wgpu")]
191 (Self::Wgpu(plan), PreparedDataset::Wgpu(dataset)) => {
192 plan.reduce_with_gradient(execution, params, dataset, reduction)
193 }
194 _ => Err(RuntimeError::InvalidShape {
195 index: 0,
196 message: "prepared model and dataset use different backends".into(),
197 }),
198 }
199 }
200}
201
202#[cfg(feature = "wgpu")]
203#[derive(Clone)]
205pub struct WgpuPlan {
206 context: std::sync::Arc<laddu_wgpu::WgpuContext>,
207 preparation_params: ParamValues,
208 kernel: std::sync::Arc<laddu_wgpu::WgpuScalarKernel>,
209}
210
211#[cfg(feature = "wgpu")]
212impl std::fmt::Debug for WgpuPlan {
213 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 formatter
215 .debug_struct("WgpuPlan")
216 .field("adapter", &self.context.info().name)
217 .finish_non_exhaustive()
218 }
219}
220
221#[cfg(feature = "wgpu")]
222#[derive(Clone)]
224pub enum WgpuPreparedDataset {
225 Resident {
227 batches: std::sync::Arc<[laddu_wgpu::WgpuPreparedBatch]>,
229 stats: PreparedDatasetStats,
231 memory_lease: crate::MemoryLease,
233 },
234 Streaming {
236 dataset: Dataset,
238 read_plan: laddu_data::io::ReadPlan,
240 workspace: std::sync::Arc<std::sync::Mutex<Option<laddu_wgpu::WgpuPreparedBatch>>>,
242 stats: PreparedDatasetStats,
244 transient_bytes: u64,
246 },
247}
248
249#[cfg(feature = "wgpu")]
250impl std::fmt::Debug for WgpuPreparedDataset {
251 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 formatter
253 .debug_struct("WgpuPreparedDataset")
254 .field("stats", self.stats())
255 .finish_non_exhaustive()
256 }
257}
258
259#[cfg(feature = "wgpu")]
260impl WgpuPreparedDataset {
261 pub fn stats(&self) -> &PreparedDatasetStats {
263 match self {
264 Self::Resident { stats, .. } | Self::Streaming { stats, .. } => stats,
265 }
266 }
267}
268
269#[cfg(feature = "wgpu")]
270impl WgpuPlan {
271 fn prepare_dataset(
272 &self,
273 execution: &Execution,
274 dataset: &Dataset,
275 ) -> RuntimeResult<WgpuPreparedDataset> {
276 let read_plan = execution.read_plan(dataset.read_plan());
277 let mut read_plan = read_plan;
278 let known_local_events = dataset
279 .num_events()
280 .map_err(|error| RuntimeError::Data(error.to_string()))?
281 .and_then(|events| usize::try_from(events).ok());
282 let discovered_events =
283 if known_local_events.is_none() && dataset.memory_policy() != MemoryPolicy::Streaming {
284 let local = (|| {
285 let mut events = 0;
286 for batch in dataset
287 .stream_with_plan(read_plan)
288 .map_err(|error| RuntimeError::Data(error.to_string()))?
289 {
290 events += batch
291 .map_err(|error| RuntimeError::Data(error.to_string()))?
292 .len();
293 }
294 Ok::<_, RuntimeError>(events)
295 })();
296 if !execution.all_succeeded(local.is_ok()) {
297 return local.and(Err(RuntimeError::DistributedPeerFailure));
298 }
299 Some(local?)
300 } else {
301 None
302 };
303 let local_event_limit = known_local_events
304 .or(discovered_events)
305 .unwrap_or(usize::MAX);
306 let prepared_footprint = self
307 .kernel
308 .prepared_memory_footprint(&self.preparation_params)
309 .map_err(|error| RuntimeError::Data(format!("GPU working-set overflow: {error}")))?;
310 let schema = dataset
311 .schema()
312 .map_err(|error| RuntimeError::Data(error.to_string()))?;
313 let host_footprint = BatchLayout::from_schema(&schema)
314 .schema_working_set(DataPrecision::F64, 2)
315 .map_err(|error| RuntimeError::Data(format!("host working-set overflow: {error}")))?;
316 let host_decision = MemoryFitRequest {
317 label: "WGPU host staging".into(),
318 footprint: host_footprint,
319 available_bytes: execution.host_memory().remaining(),
320 event_limit: local_event_limit,
321 strategy: "bounded host staging".into(),
322 }
323 .evaluate()?;
324 let host_chunks = local_event_limit
325 .saturating_add(host_decision.chunk_events.saturating_sub(1))
326 / host_decision.chunk_events.max(1);
327 let resident_footprint = MemoryFootprint::fixed(prepared_footprint.fixed_bytes)
328 .checked_scale_usize(host_chunks)
329 .and_then(|fixed| {
330 fixed.checked_add(MemoryFootprint::per_event(
331 prepared_footprint.bytes_per_event,
332 ))
333 })
334 .map_err(|error| RuntimeError::Data(format!("GPU working-set overflow: {error}")))?;
335 let resident_peak = resident_footprint.peak_bytes(local_event_limit);
336 let device_pool = execution
337 .device_memory()
338 .ok_or_else(|| RuntimeError::Wgpu("GPU execution has no device memory pool".into()))?;
339 let device_available = device_pool.remaining();
340 let storage = match dataset.memory_policy() {
341 MemoryPolicy::Streaming => CacheStorage::Streaming,
342 MemoryPolicy::Resident => {
343 if resident_peak > device_available {
344 return Err(laddu_memory::MemoryError::BudgetExceeded {
345 resource: "device".into(),
346 requested: resident_peak,
347 remaining: device_available,
348 }
349 .into());
350 }
351 CacheStorage::Resident
352 }
353 MemoryPolicy::Fastest if resident_peak <= device_available => CacheStorage::Resident,
354 MemoryPolicy::Fastest => CacheStorage::Streaming,
355 };
356 let memory_lease = if storage == CacheStorage::Resident {
357 Some(device_pool.reserve(resident_peak)?)
358 } else {
359 None
360 };
361 let device_decision = if storage == CacheStorage::Resident {
362 MemoryFitRequest {
363 label: "WGPU prepared dataset".into(),
364 footprint: resident_footprint,
365 available_bytes: device_available,
366 event_limit: local_event_limit,
367 strategy: "resident".into(),
368 }
369 .evaluate_resident(host_decision.chunk_events)?
370 } else {
371 MemoryFitRequest {
372 label: "WGPU prepared dataset".into(),
373 footprint: prepared_footprint,
374 available_bytes: device_available,
375 event_limit: local_event_limit,
376 strategy: "streaming".into(),
377 }
378 .evaluate()?
379 };
380 let chunk_events = device_decision
381 .chunk_events
382 .min(host_decision.chunk_events)
383 .max(1);
384 read_plan.chunk_size = Some(
385 read_plan
386 .chunk_size
387 .map_or(chunk_events, |manual| manual.min(chunk_events))
388 .max(1),
389 );
390 execution.record_memory_decision(device_decision.clone());
391 execution.record_memory_decision(host_decision);
392 let local = (|| {
393 let mut batches = Vec::new();
394 let mut events = 0;
395 let mut batch_count = 0;
396 let mut sum_weights = AccurateF64::zero();
397 for batch in dataset
398 .stream_with_plan(read_plan)
399 .map_err(|error| RuntimeError::Data(error.to_string()))?
400 {
401 let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
402 events += batch.len();
403 batch_count += 1;
404 for row in 0..batch.len() {
405 sum_weights.push(batch.weights_at(row));
406 }
407 if storage == CacheStorage::Resident {
408 batches.push(
409 self.kernel
410 .prepare_batch(&self.context, &self.preparation_params, &batch)
411 .map_err(wgpu_error)?,
412 );
413 }
414 }
415 Ok::<_, RuntimeError>((batches, events, batch_count, sum_weights.finish()))
416 })();
417 if !execution.all_succeeded(local.is_ok()) {
418 return local.and(Err(RuntimeError::DistributedPeerFailure));
419 }
420 let (batches, events, batch_count, sum_weights) = local?;
421 let resident_bytes = batches
422 .iter()
423 .map(laddu_wgpu::WgpuPreparedBatch::resident_bytes)
424 .sum();
425 let stats = PreparedDatasetStats::new(
426 events,
427 execution.sum_usize(events),
428 batch_count,
429 execution.sum_f64(sum_weights),
430 resident_bytes,
431 storage,
432 );
433 Ok(match storage {
434 CacheStorage::Resident => WgpuPreparedDataset::Resident {
435 batches: batches.into(),
436 stats,
437 memory_lease: memory_lease.ok_or_else(|| {
438 RuntimeError::Wgpu("resident GPU dataset did not reserve device memory".into())
439 })?,
440 },
441 CacheStorage::Streaming => WgpuPreparedDataset::Streaming {
442 dataset: dataset.clone(),
443 read_plan,
444 workspace: Default::default(),
445 stats,
446 transient_bytes: device_decision.estimated_peak_bytes,
447 },
448 })
449 }
450
451 fn reduce(
452 &self,
453 execution: &Execution,
454 params: &ParamValues,
455 dataset: &WgpuPreparedDataset,
456 reduction: ReductionPlan,
457 ) -> RuntimeResult<f64> {
458 let mut total = AccurateF64::zero();
459 match dataset {
460 WgpuPreparedDataset::Resident { batches, .. } => {
461 for batch in batches.iter() {
462 total.push(
463 self.kernel
464 .reduce_prepared_batch(&self.context, params, batch, reduction)
465 .map_err(wgpu_error)?,
466 );
467 }
468 }
469 WgpuPreparedDataset::Streaming {
470 dataset,
471 read_plan,
472 workspace,
473 transient_bytes,
474 ..
475 } => {
476 let _memory = execution
477 .device_memory()
478 .ok_or_else(|| {
479 RuntimeError::Wgpu("GPU execution has no device memory pool".into())
480 })?
481 .reserve(*transient_bytes)?;
482 let mut workspace = workspace.lock().map_err(|_| {
483 RuntimeError::Wgpu("streaming workspace lock is poisoned".into())
484 })?;
485 for batch in dataset
486 .stream_with_plan(*read_plan)
487 .map_err(|error| RuntimeError::Data(error.to_string()))?
488 {
489 let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
490 if let Some(prepared) = workspace.as_mut() {
491 if !self
492 .kernel
493 .refresh_batch(
494 &self.context,
495 &self.preparation_params,
496 &batch,
497 prepared,
498 )
499 .map_err(wgpu_error)?
500 {
501 *prepared = self
502 .kernel
503 .prepare_batch(&self.context, &self.preparation_params, &batch)
504 .map_err(wgpu_error)?;
505 }
506 } else {
507 *workspace = Some(
508 self.kernel
509 .prepare_batch(&self.context, &self.preparation_params, &batch)
510 .map_err(wgpu_error)?,
511 );
512 }
513 total.push(
514 self.kernel
515 .reduce_prepared_batch(
516 &self.context,
517 params,
518 workspace
519 .as_ref()
520 .expect("streaming workspace was initialized"),
521 reduction,
522 )
523 .map_err(wgpu_error)?,
524 );
525 }
526 }
527 }
528 Ok(execution.sum_f64(total.finish()))
529 }
530
531 fn reduce_with_gradient(
532 &self,
533 execution: &Execution,
534 params: &ParamValues,
535 dataset: &WgpuPreparedDataset,
536 reduction: ReductionPlan,
537 ) -> RuntimeResult<ReductionEvaluation> {
538 let mut total = AccurateF64::zero();
539 let mut gradient = (0..params.layout().n_free())
540 .map(|_| AccurateF64::zero())
541 .collect::<Vec<_>>();
542 let mut consume = |batch: &laddu_wgpu::WgpuPreparedBatch| -> RuntimeResult<()> {
543 let (value, values) = self
544 .kernel
545 .reduce_prepared_batch_with_gradient(&self.context, params, batch, reduction)
546 .map_err(wgpu_error)?;
547 total.push(value);
548 for (sum, value) in gradient.iter_mut().zip(values) {
549 sum.push(value);
550 }
551 Ok(())
552 };
553 match dataset {
554 WgpuPreparedDataset::Resident { batches, .. } => {
555 for batch in batches.iter() {
556 consume(batch)?;
557 }
558 }
559 WgpuPreparedDataset::Streaming {
560 dataset,
561 read_plan,
562 workspace,
563 transient_bytes,
564 ..
565 } => {
566 let _memory = execution
567 .device_memory()
568 .ok_or_else(|| {
569 RuntimeError::Wgpu("GPU execution has no device memory pool".into())
570 })?
571 .reserve(*transient_bytes)?;
572 let mut workspace = workspace.lock().map_err(|_| {
573 RuntimeError::Wgpu("streaming workspace lock is poisoned".into())
574 })?;
575 for batch in dataset
576 .stream_with_plan(*read_plan)
577 .map_err(|error| RuntimeError::Data(error.to_string()))?
578 {
579 let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
580 if let Some(prepared) = workspace.as_mut() {
581 if !self
582 .kernel
583 .refresh_batch(
584 &self.context,
585 &self.preparation_params,
586 &batch,
587 prepared,
588 )
589 .map_err(wgpu_error)?
590 {
591 *prepared = self
592 .kernel
593 .prepare_batch(&self.context, &self.preparation_params, &batch)
594 .map_err(wgpu_error)?;
595 }
596 } else {
597 *workspace = Some(
598 self.kernel
599 .prepare_batch(&self.context, &self.preparation_params, &batch)
600 .map_err(wgpu_error)?,
601 );
602 }
603 consume(
604 workspace
605 .as_ref()
606 .expect("streaming workspace was initialized"),
607 )?;
608 }
609 }
610 }
611 let gradient = gradient
612 .into_iter()
613 .map(|sum| execution.sum_f64(sum.finish()))
614 .collect();
615 Ok(ReductionEvaluation::new(
616 execution.sum_f64(total.finish()),
617 gradient,
618 ))
619 }
620}
621
622#[cfg(feature = "wgpu")]
623fn wgpu_error(error: laddu_wgpu::WgpuError) -> RuntimeError {
624 RuntimeError::Wgpu(error.to_string())
625}
626
627#[cfg(all(test, feature = "wgpu"))]
628mod tests {
629 use std::sync::Arc;
630
631 use laddu_compile::{CompiledModel, ReductionPlan};
632 use laddu_data::{
633 data::{Dataset, EventBatch, OwnedEvent},
634 schema::Schema,
635 };
636 use laddu_expr::{complex, event_scalar, parameter};
637
638 use super::*;
639 use crate::{CpuOptions, Device, ExecutionOptions, GpuBackend, GpuOptions, Precision};
640
641 #[test]
642 #[ignore = "requires a WGPU-compatible hardware adapter"]
643 fn wgpu_resident_and_streaming_reductions_match_f32_cpu() {
644 let scale = laddu_expr::Expr::from(parameter!("scale", initial: 1.25));
645 let offset = laddu_expr::Expr::from(parameter!("offset", initial: 0.5));
646 let x = event_scalar("x");
647 let expression = (x.clone() * scale.clone() + offset.clone()).sin()
648 + complex(scale, offset).norm_sqr()
649 + 2.0;
650 let model = CompiledModel::from_expr(&expression).unwrap();
651 let params = model.params().default_values();
652 let schema = Arc::new(Schema::new(std::iter::empty::<&str>(), ["x"], true).unwrap());
653 let dataset = Dataset::from_batches(vec![
654 EventBatch::from_events(
655 schema.clone(),
656 [
657 OwnedEvent::weighted(vec![], vec![0.25], 0.5),
658 OwnedEvent::weighted(vec![], vec![0.75], 1.5),
659 ],
660 )
661 .unwrap(),
662 EventBatch::from_events(schema, [OwnedEvent::weighted(vec![], vec![1.25], 2.0)])
663 .unwrap(),
664 ])
665 .unwrap();
666 let wgpu_execution = Execution::local(ExecutionOptions {
667 device: Device::Gpu(GpuOptions {
668 backend: GpuBackend::Wgpu,
669 ..GpuOptions::default()
670 }),
671 memory: crate::MemoryPlan::host_device(
672 crate::MemoryBudget::Auto,
673 crate::MemoryBudget::Bytes(256),
674 ),
675 precision: Precision::F32,
676 ..ExecutionOptions::default()
677 })
678 .unwrap();
679 let cpu_execution = Execution::local(ExecutionOptions {
680 device: Device::Cpu(CpuOptions::default()),
681 precision: Precision::F32,
682 ..ExecutionOptions::default()
683 })
684 .unwrap();
685 let wgpu = PreparedModel::prepare(&model, &wgpu_execution).unwrap();
686 let cpu = PreparedModel::prepare(&model, &cpu_execution).unwrap();
687 let resident = wgpu
688 .prepare_dataset(&wgpu_execution, &dataset.clone().resident())
689 .unwrap();
690 let streaming = wgpu
691 .prepare_dataset(&wgpu_execution, &dataset.clone().streaming())
692 .unwrap();
693 let cpu_data = cpu.prepare_dataset(&cpu_execution, &dataset).unwrap();
694
695 assert_eq!(resident.stats().storage(), CacheStorage::Resident);
696 assert_eq!(streaming.stats().storage(), CacheStorage::Streaming);
697 assert!(resident.stats().resident_bytes() > 0);
698 assert_eq!(streaming.stats().resident_bytes(), 0);
699
700 let cpu_reduction = cpu
701 .reduce_with_gradient(
702 &cpu_execution,
703 ¶ms,
704 &cpu_data,
705 ReductionPlan::weighted_real(),
706 )
707 .unwrap();
708 let resident_reduction = wgpu
709 .reduce_with_gradient(
710 &wgpu_execution,
711 ¶ms,
712 &resident,
713 ReductionPlan::weighted_real(),
714 )
715 .unwrap();
716 let streaming_reduction = wgpu
717 .reduce_with_gradient(
718 &wgpu_execution,
719 ¶ms,
720 &streaming,
721 ReductionPlan::weighted_real(),
722 )
723 .unwrap();
724
725 for actual in [&resident_reduction, &streaming_reduction] {
726 assert!((actual.value() - cpu_reduction.value()).abs() <= 1.0e-4);
727 assert_eq!(actual.gradient().len(), cpu_reduction.gradient().len());
728 for (actual, expected) in actual.gradient().iter().zip(cpu_reduction.gradient()) {
729 assert!((actual - expected).abs() <= 1.0e-4);
730 }
731 }
732 assert!((resident_reduction.value() - streaming_reduction.value()).abs() <= 1.0e-6);
733 for (resident, streaming) in resident_reduction
734 .gradient()
735 .iter()
736 .zip(streaming_reduction.gradient())
737 {
738 assert!((resident - streaming).abs() <= 1.0e-6);
739 }
740 }
741}