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