1use laddu_compile::{
2 CompiledModel, NormalizationDiagnostics, NormalizationStrategy, ReductionPlan,
3};
4use laddu_data::{
5 data::{CacheStorage, Dataset},
6 io::ReadPlan,
7};
8use laddu_expr::parameters::{ParamError, ParamLayout, ParamProjection, ParamValues};
9use laddu_memory::{MemoryFitRequest, MemoryFootprint};
10use num::complex::{Complex32, Complex64};
11use std::sync::{
12 Arc,
13 atomic::{AtomicBool, Ordering},
14};
15
16use crate::{
17 CpuBackend, CpuPlan, Execution, MemoryLease, NormalizationMode, PreparedDataset,
18 PreparedDatasetStats, PreparedModel, RuntimeError, RuntimeResult,
19};
20
21const AUTO_BREAK_EVEN_EVALUATIONS: usize = 16;
22
23#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct PreparedNormalizationDiagnostics {
26 strategy: NormalizationStrategy,
27 compiler: NormalizationDiagnostics,
28 retained_bytes: usize,
29 preparation_passes: usize,
30 cache_hit: bool,
31 tag_projection_reused_parent: bool,
32}
33
34impl PreparedNormalizationDiagnostics {
35 pub fn strategy(&self) -> NormalizationStrategy {
37 self.strategy
38 }
39
40 pub fn compiler(&self) -> &NormalizationDiagnostics {
42 &self.compiler
43 }
44
45 pub fn retained_bytes(&self) -> usize {
47 self.retained_bytes
48 }
49
50 pub fn preparation_passes(&self) -> usize {
52 self.preparation_passes
53 }
54
55 pub fn cache_hit(&self) -> bool {
57 self.cache_hit
58 }
59
60 pub fn tag_projection_reused_parent(&self) -> bool {
62 self.tag_projection_reused_parent
63 }
64
65 #[doc(hidden)]
67 pub fn general(compiler: NormalizationDiagnostics) -> Self {
68 Self {
69 strategy: NormalizationStrategy::General,
70 compiler,
71 retained_bytes: 0,
72 preparation_passes: 1,
73 cache_hit: false,
74 tag_projection_reused_parent: false,
75 }
76 }
77}
78
79#[derive(Clone, Debug)]
80struct GeneralResidual {
81 plan: PreparedModel,
82 dataset: PreparedDataset,
83 parameters: ParamProjection,
84}
85
86#[derive(Debug)]
87enum StoredStatistics {
88 F32(Vec<Complex32>),
89 F64(Vec<Complex64>),
90}
91
92impl StoredStatistics {
93 fn from_f64(values: Vec<Complex64>, precision: crate::Precision) -> Self {
94 if precision == crate::Precision::F32 {
95 Self::F32(
96 values
97 .into_iter()
98 .map(|value| Complex32::new(value.re as f32, value.im as f32))
99 .collect(),
100 )
101 } else {
102 Self::F64(values)
103 }
104 }
105
106 fn resident_bytes(&self) -> usize {
107 match self {
108 Self::F32(values) => values.capacity() * std::mem::size_of::<Complex32>(),
109 Self::F64(values) => values.capacity() * std::mem::size_of::<Complex64>(),
110 }
111 }
112
113 fn evaluator_values(&self) -> Vec<Complex64> {
114 match self {
115 Self::F32(values) => values
116 .iter()
117 .map(|value| Complex64::new(value.re as f64, value.im as f64))
118 .collect(),
119 Self::F64(values) => values.clone(),
120 }
121 }
122}
123
124fn normalization_projection(
125 child: &ParamLayout,
126 parent: &ParamLayout,
127) -> RuntimeResult<ParamProjection> {
128 child.projection_from(parent).map_err(|error| match error {
129 ParamError::UnknownName(name) => RuntimeError::Data(format!(
130 "normalization parameter `{name}` is absent from the source model"
131 )),
132 ParamError::ParameterConflict { name, .. } => RuntimeError::Data(format!(
133 "normalization parameter `{name}` is unexpectedly fixed in the source model"
134 )),
135 error => RuntimeError::Parameter(error.to_string()),
136 })
137}
138
139fn project_normalization(
140 projection: &ParamProjection,
141 params: &ParamValues,
142) -> RuntimeResult<ParamValues> {
143 projection.project(params).map_err(|error| match error {
144 ParamError::UnknownName(name) => RuntimeError::Data(format!(
145 "normalization parameter `{name}` is absent from supplied values"
146 )),
147 error => RuntimeError::Parameter(error.to_string()),
148 })
149}
150
151#[derive(Debug)]
153pub struct PreparedNormalization {
154 evaluator: CpuPlan,
155 evaluator_parameters: ParamProjection,
156 statistics: StoredStatistics,
157 residual: Option<GeneralResidual>,
158 verification: Option<GeneralResidual>,
159 stats: PreparedDatasetStats,
160 diagnostics: PreparedNormalizationDiagnostics,
161 cache_reused: AtomicBool,
162 _memory_lease: MemoryLease,
163}
164
165#[derive(Debug)]
166struct NormalizationEvaluation {
167 value: f64,
168 gradient: Option<Vec<f64>>,
169}
170
171impl PreparedNormalization {
172 pub fn prepare(
179 model: &CompiledModel,
180 general_plan: &PreparedModel,
181 dataset: &Dataset,
182 execution: &Execution,
183 ) -> RuntimeResult<Option<Arc<Self>>> {
184 if execution.normalization_mode() == NormalizationMode::General
185 || model.normalization_diagnostics().strategy() == NormalizationStrategy::General
186 || (execution.normalization_mode() == NormalizationMode::Auto
187 && !model.normalization_plan().proven_nonnegative())
188 {
189 return Ok(None);
190 }
191
192 let key = (
193 model.optimized_digest(),
194 dataset.identity(),
195 execution.normalization_mode(),
196 );
197 let mut cache = execution
198 .normalization_cache()
199 .lock()
200 .unwrap_or_else(|error| error.into_inner());
201 cache.retain(|_, prepared| prepared.strong_count() > 0);
202 if let Some(prepared) = cache.get(&key).and_then(std::sync::Weak::upgrade) {
203 prepared.cache_reused.store(true, Ordering::Relaxed);
204 return Ok(Some(prepared));
205 }
206 let Some(prepared) = Self::prepare_uncached(model, general_plan, dataset, execution)?
207 else {
208 return Ok(None);
209 };
210 let prepared = Arc::new(prepared);
211 cache.insert(key, Arc::downgrade(&prepared));
212 Ok(Some(prepared))
213 }
214
215 fn prepare_uncached(
216 model: &CompiledModel,
217 general_plan: &PreparedModel,
218 dataset: &Dataset,
219 execution: &Execution,
220 ) -> RuntimeResult<Option<Self>> {
221 let basis_models = model
222 .normalization_plan()
223 .basis_models()
224 .map_err(|error| RuntimeError::Data(error.to_string()))?;
225 let basis_work = basis_models
226 .iter()
227 .map(|basis| basis.graph().nodes().len())
228 .sum::<usize>();
229 let general_work = model.graph().nodes().len().max(1);
230 if execution.normalization_mode() == NormalizationMode::Auto
231 && basis_work > general_work.saturating_mul(AUTO_BREAK_EVEN_EVALUATIONS)
232 {
233 return Ok(None);
234 }
235
236 let statistic_bytes = if execution.precision() == crate::Precision::F32 {
237 std::mem::size_of::<Complex32>()
238 } else {
239 std::mem::size_of::<Complex64>()
240 };
241 let retained_bytes = basis_models.len().saturating_mul(statistic_bytes);
242 let memory_lease = match execution
243 .host_memory()
244 .reserve(u64::try_from(retained_bytes).unwrap_or(u64::MAX))
245 {
246 Ok(lease) => lease,
247 Err(_) if execution.normalization_mode() == NormalizationMode::Auto => return Ok(None),
248 Err(error) => return Err(error.into()),
249 };
250 let basis_plans = basis_models
251 .iter()
252 .map(|basis| {
253 CpuBackend
254 .prepare_with_autodiff_mode(basis, execution.autodiff_mode())
255 .map_err(|error| RuntimeError::Data(error.to_string()))
256 })
257 .collect::<RuntimeResult<Vec<_>>>()?;
258 let basis_params = basis_models
259 .iter()
260 .map(|basis| basis.params().default_values())
261 .collect::<Vec<_>>();
262 let (statistics, stats) =
263 accumulate_statistics(&basis_plans, &basis_params, dataset, execution)?;
264 let statistics = StoredStatistics::from_f64(statistics, execution.precision());
265 let evaluator_statistics = statistics.evaluator_values();
266 let evaluator_model = model
267 .normalization_plan()
268 .evaluator_model(&evaluator_statistics)
269 .map_err(|error| RuntimeError::Data(error.to_string()))?;
270 let evaluator = CpuBackend
274 .prepare_with_autodiff_mode(&evaluator_model, execution.autodiff_mode())
275 .map_err(|error| RuntimeError::Data(error.to_string()))?;
276 let evaluator_parameters =
277 normalization_projection(evaluator_model.params(), model.params())?;
278
279 let residual_model = model
280 .normalization_plan()
281 .residual_model()
282 .map_err(|error| RuntimeError::Data(error.to_string()))?;
283 let residual = if let Some(residual_model) = residual_model {
284 let parameters = normalization_projection(residual_model.params(), model.params())?;
285 let plan = PreparedModel::prepare(&residual_model, execution)?;
286 let dataset = plan.prepare_dataset(execution, dataset)?;
287 Some(GeneralResidual {
288 plan,
289 dataset,
290 parameters,
291 })
292 } else {
293 None
294 };
295 let verification = if execution.normalization_mode() == NormalizationMode::Verify {
296 Some(GeneralResidual {
297 plan: general_plan.clone(),
298 dataset: general_plan.prepare_dataset(execution, dataset)?,
299 parameters: normalization_projection(model.params(), model.params())?,
300 })
301 } else {
302 None
303 };
304 let preparation_passes =
305 1 + usize::from(residual.is_some()) + usize::from(verification.is_some());
306 Ok(Some(Self {
307 evaluator,
308 evaluator_parameters,
309 statistics,
310 residual,
311 verification,
312 stats,
313 diagnostics: PreparedNormalizationDiagnostics {
314 strategy: model.normalization_diagnostics().strategy(),
315 compiler: model.normalization_diagnostics().clone(),
316 retained_bytes,
317 preparation_passes,
318 cache_hit: false,
319 tag_projection_reused_parent: false,
320 },
321 cache_reused: AtomicBool::new(false),
322 _memory_lease: memory_lease,
323 }))
324 }
325
326 pub fn stats(&self) -> &PreparedDatasetStats {
328 &self.stats
329 }
330
331 pub fn diagnostics(&self) -> PreparedNormalizationDiagnostics {
333 let mut diagnostics = self.diagnostics.clone();
334 diagnostics.cache_hit = self.cache_reused.load(Ordering::Relaxed);
335 diagnostics
336 }
337
338 pub fn resident_bytes(&self) -> usize {
340 self.statistics.resident_bytes()
341 }
342
343 pub fn value(&self, params: &ParamValues, execution: &Execution) -> RuntimeResult<f64> {
350 Ok(self.evaluate_composed(params, execution, false)?.value)
351 }
352
353 pub fn value_gradient(
360 &self,
361 params: &ParamValues,
362 execution: &Execution,
363 ) -> RuntimeResult<(f64, Vec<f64>)> {
364 let evaluation = self.evaluate_composed(params, execution, true)?;
365 Ok((
366 evaluation.value,
367 evaluation.gradient.ok_or_else(|| {
368 RuntimeError::Data("normalization gradient composition produced no gradient".into())
369 })?,
370 ))
371 }
372
373 fn evaluate_composed(
374 &self,
375 params: &ParamValues,
376 execution: &Execution,
377 with_gradient: bool,
378 ) -> RuntimeResult<NormalizationEvaluation> {
379 let evaluator_params = project_normalization(&self.evaluator_parameters, params)?;
380 let (mut value, mut gradient) = if with_gradient {
381 let evaluation = self.evaluator.evaluate_with_gradient(&evaluator_params)?;
382 let mut gradient = vec![0.0; params.layout().n_free()];
383 let evaluator_gradient = evaluation
384 .gradient()
385 .iter()
386 .map(|value| value.re)
387 .collect::<Vec<_>>();
388 self.evaluator_parameters
389 .scatter_add(&evaluator_gradient, &mut gradient)
390 .map_err(|_| incompatible_gradient_layout())?;
391 (evaluation.value().re, Some(gradient))
392 } else {
393 (self.evaluator.evaluate(&evaluator_params)?.re, None)
394 };
395 if let Some(residual) = &self.residual {
396 let residual_params = project_normalization(&residual.parameters, params)?;
397 if let Some(gradient) = &mut gradient {
398 let residual_evaluation = residual.plan.reduce_with_gradient(
399 execution,
400 &residual_params,
401 &residual.dataset,
402 ReductionPlan::weighted_real(),
403 )?;
404 value += residual_evaluation.value();
405 residual
406 .parameters
407 .scatter_add(residual_evaluation.gradient(), gradient)
408 .map_err(|_| incompatible_gradient_layout())?;
409 } else {
410 value += residual.plan.reduce(
411 execution,
412 &residual_params,
413 &residual.dataset,
414 ReductionPlan::weighted_real(),
415 )?;
416 }
417 }
418 if let Some(general) = &self.verification {
419 let general_params = project_normalization(&general.parameters, params)?;
420 if let Some(gradient) = &gradient {
421 let expected = general.plan.reduce_with_gradient(
422 execution,
423 &general_params,
424 &general.dataset,
425 ReductionPlan::weighted_real(),
426 )?;
427 verify_close("normalization value", value, expected.value(), execution)?;
428 for (index, (actual, expected)) in
429 gradient.iter().zip(expected.gradient()).enumerate()
430 {
431 verify_close(
432 &format!("normalization gradient[{index}]"),
433 *actual,
434 *expected,
435 execution,
436 )?;
437 }
438 } else {
439 let expected = general.plan.reduce(
440 execution,
441 &general_params,
442 &general.dataset,
443 ReductionPlan::weighted_real(),
444 )?;
445 verify_close("normalization value", value, expected, execution)?;
446 }
447 }
448 Ok(NormalizationEvaluation { value, gradient })
449 }
450}
451
452fn incompatible_gradient_layout() -> RuntimeError {
453 RuntimeError::Data("normalization gradient has an incompatible parameter layout".into())
454}
455
456fn accumulate_statistics(
457 plans: &[CpuPlan],
458 params: &[ParamValues],
459 dataset: &Dataset,
460 execution: &Execution,
461) -> RuntimeResult<(Vec<Complex64>, PreparedDatasetStats)> {
462 let mut sums = vec![Complex64::ZERO; plans.len()];
463 let mut corrections = vec![Complex64::ZERO; plans.len()];
464 let mut read_plan: ReadPlan = execution.read_plan(dataset.read_plan());
465 let local_limit = dataset
466 .num_events()
467 .map_err(|error| RuntimeError::Data(error.to_string()))?
468 .and_then(|events| usize::try_from(events).ok())
469 .unwrap_or(usize::MAX);
470 let statistic_bytes = plans.len().saturating_mul(std::mem::size_of::<Complex64>());
471 let decision = MemoryFitRequest {
472 label: "normalization statistics".into(),
473 footprint: MemoryFootprint::from_usize(statistic_bytes, statistic_bytes),
474 available_bytes: execution.host_memory().remaining(),
475 event_limit: local_limit,
476 strategy: "single-pass sufficient statistics".into(),
477 }
478 .evaluate()?;
479 read_plan.chunk_size = Some(
480 read_plan
481 .chunk_size
482 .map_or(decision.chunk_events, |manual| {
483 manual.min(decision.chunk_events)
484 })
485 .max(1),
486 );
487 execution.record_memory_decision(decision);
488 let local = (|| {
489 let mut events = 0usize;
490 let mut batches = 0usize;
491 let mut weight_sum = 0.0;
492 let mut weight_correction = 0.0;
493 for batch in dataset
494 .stream_with_plan(read_plan)
495 .map_err(|error| RuntimeError::Data(error.to_string()))?
496 {
497 let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
498 events += batch.len();
499 batches += 1;
500 for row in 0..batch.len() {
501 let weight = batch.weights_at(row);
502 let corrected = weight - weight_correction;
503 let next = weight_sum + corrected;
504 weight_correction = (next - weight_sum) - corrected;
505 weight_sum = next;
506 }
507 for (index, (plan, params)) in plans.iter().zip(params).enumerate() {
508 for (row, value) in plan.evaluate_batch(params, &batch)?.into_iter().enumerate() {
509 let value = value * batch.weights_at(row);
510 let corrected = value - corrections[index];
511 let next = sums[index] + corrected;
512 corrections[index] = (next - sums[index]) - corrected;
513 sums[index] = next;
514 }
515 }
516 }
517 Ok::<_, RuntimeError>((events, batches, weight_sum))
518 })();
519 if !execution.all_succeeded(local.is_ok()) {
520 return local.and(Err(RuntimeError::DistributedPeerFailure));
521 }
522 let (events, batches, weight_sum) = local?;
523 for sum in &mut sums {
524 sum.re = execution.sum_f64(sum.re);
525 sum.im = execution.sum_f64(sum.im);
526 }
527 let stats = PreparedDatasetStats::new(
528 events,
529 execution.sum_usize(events),
530 batches,
531 execution.sum_f64(weight_sum),
532 sums.len() * std::mem::size_of::<Complex64>(),
533 CacheStorage::Resident,
534 );
535 Ok((sums, stats))
536}
537
538fn verify_close(
539 label: &str,
540 actual: f64,
541 expected: f64,
542 execution: &Execution,
543) -> RuntimeResult<()> {
544 let tolerance = match execution.precision() {
545 crate::Precision::F32 => 5.0e-4,
546 crate::Precision::Auto | crate::Precision::F64 => 1.0e-10,
547 } * expected.abs().max(1.0);
548 if (actual - expected).abs() <= tolerance {
549 Ok(())
550 } else {
551 Err(RuntimeError::Data(format!(
552 "{label} verification failed: compiler-native={actual}, general={expected}, tolerance={tolerance}"
553 )))
554 }
555}