Skip to main content

laddu_runtime/
normalization.rs

1use laddu_compile::{
2    CompiledModel, NormalizationDiagnostics, NormalizationStrategy, ReductionPlan,
3};
4use laddu_data::{
5    data::{CacheStorage, Dataset},
6    io::ReadPlan,
7};
8use laddu_expr::parameters::{ParamLayout, 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/// Runtime diagnostics for one compiler-native accepted normalization.
24#[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    /// Returns the runtime-selected normalization strategy.
36    pub fn strategy(&self) -> NormalizationStrategy {
37        self.strategy
38    }
39
40    /// Returns compiler analysis diagnostics.
41    pub fn compiler(&self) -> &NormalizationDiagnostics {
42        &self.compiler
43    }
44
45    /// Returns retained sufficient-statistic bytes on this rank.
46    pub fn retained_bytes(&self) -> usize {
47        self.retained_bytes
48    }
49
50    /// Returns the number of accepted-source passes used during preparation.
51    pub fn preparation_passes(&self) -> usize {
52        self.preparation_passes
53    }
54
55    /// Returns whether an execution-scoped prepared artifact was reused.
56    pub fn cache_hit(&self) -> bool {
57        self.cache_hit
58    }
59
60    /// Returns whether a tag projection reused parent statistics.
61    pub fn tag_projection_reused_parent(&self) -> bool {
62        self.tag_projection_reused_parent
63    }
64
65    /// Constructs diagnostics for an ordinary prepared event reduction.
66    #[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: ParameterMapping,
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
124#[derive(Clone, Debug)]
125struct ParameterMapping {
126    layout: ParamLayout,
127    child_to_parent_free: Vec<usize>,
128    parent_free: usize,
129}
130
131impl ParameterMapping {
132    fn new(child: &ParamLayout, parent: &ParamLayout) -> RuntimeResult<Self> {
133        let child_to_parent_free = child
134            .free_params()
135            .iter()
136            .map(|child_id| {
137                let name = child
138                    .name(*child_id)
139                    .map_err(|error| RuntimeError::Parameter(error.to_string()))?;
140                let parent_id = parent.id(name).ok_or_else(|| {
141                    RuntimeError::Data(format!(
142                        "normalization parameter `{name}` is absent from the source model"
143                    ))
144                })?;
145                parent
146                    .free_id(parent_id)
147                    .map_err(|error| RuntimeError::Parameter(error.to_string()))?
148                    .map(|id| id.index())
149                    .ok_or_else(|| {
150                        RuntimeError::Data(format!(
151                            "normalization parameter `{name}` is unexpectedly fixed in the source model"
152                        ))
153                    })
154            })
155            .collect::<RuntimeResult<Vec<_>>>()?;
156        Ok(Self {
157            layout: child.clone(),
158            child_to_parent_free,
159            parent_free: parent.n_free(),
160        })
161    }
162
163    fn project(&self, parent: &ParamValues) -> RuntimeResult<ParamValues> {
164        let free = self
165            .layout
166            .free_params()
167            .iter()
168            .map(|child_id| {
169                let name = self
170                    .layout
171                    .name(*child_id)
172                    .map_err(|error| RuntimeError::Parameter(error.to_string()))?;
173                let parent_id = parent.layout().id(name).ok_or_else(|| {
174                    RuntimeError::Data(format!(
175                        "normalization parameter `{name}` is absent from supplied values"
176                    ))
177                })?;
178                parent
179                    .get(parent_id)
180                    .map_err(|error| RuntimeError::Parameter(error.to_string()))
181            })
182            .collect::<RuntimeResult<Vec<_>>>()?;
183        self.layout
184            .values(&free)
185            .map_err(|error| RuntimeError::Parameter(error.to_string()))
186    }
187
188    fn scatter_add(&self, child: &[f64], parent: &mut [f64]) -> RuntimeResult<()> {
189        if child.len() != self.child_to_parent_free.len() || parent.len() != self.parent_free {
190            return Err(RuntimeError::Data(
191                "normalization gradient has an incompatible parameter layout".into(),
192            ));
193        }
194        for (value, parent_index) in child.iter().zip(&self.child_to_parent_free) {
195            parent[*parent_index] += value;
196        }
197        Ok(())
198    }
199}
200
201/// Prepared sufficient statistics and their parameter-only contraction.
202#[derive(Debug)]
203pub struct PreparedNormalization {
204    evaluator: CpuPlan,
205    evaluator_parameters: ParameterMapping,
206    statistics: StoredStatistics,
207    residual: Option<GeneralResidual>,
208    verification: Option<GeneralResidual>,
209    stats: PreparedDatasetStats,
210    diagnostics: PreparedNormalizationDiagnostics,
211    cache_reused: AtomicBool,
212    _memory_lease: MemoryLease,
213}
214
215impl PreparedNormalization {
216    /// Prepares compiler-native normalization when selected by execution policy.
217    ///
218    /// # Errors
219    ///
220    /// Returns a runtime error when basis compilation, dataset traversal,
221    /// memory reservation, or backend preparation fails.
222    pub fn prepare(
223        model: &CompiledModel,
224        general_plan: &PreparedModel,
225        dataset: &Dataset,
226        execution: &Execution,
227    ) -> RuntimeResult<Option<Arc<Self>>> {
228        if execution.normalization_mode() == NormalizationMode::General
229            || model.normalization_diagnostics().strategy() == NormalizationStrategy::General
230            || (execution.normalization_mode() == NormalizationMode::Auto
231                && !model.normalization_plan().proven_nonnegative())
232        {
233            return Ok(None);
234        }
235
236        let key = (
237            model.optimized_digest(),
238            dataset.identity(),
239            execution.normalization_mode(),
240        );
241        let mut cache = execution
242            .normalization_cache()
243            .lock()
244            .unwrap_or_else(|error| error.into_inner());
245        cache.retain(|_, prepared| prepared.strong_count() > 0);
246        if let Some(prepared) = cache.get(&key).and_then(std::sync::Weak::upgrade) {
247            prepared.cache_reused.store(true, Ordering::Relaxed);
248            return Ok(Some(prepared));
249        }
250        let Some(prepared) = Self::prepare_uncached(model, general_plan, dataset, execution)?
251        else {
252            return Ok(None);
253        };
254        let prepared = Arc::new(prepared);
255        cache.insert(key, Arc::downgrade(&prepared));
256        Ok(Some(prepared))
257    }
258
259    fn prepare_uncached(
260        model: &CompiledModel,
261        general_plan: &PreparedModel,
262        dataset: &Dataset,
263        execution: &Execution,
264    ) -> RuntimeResult<Option<Self>> {
265        let basis_models = model
266            .normalization_plan()
267            .basis_models()
268            .map_err(|error| RuntimeError::Data(error.to_string()))?;
269        let basis_work = basis_models
270            .iter()
271            .map(|basis| basis.graph().nodes().len())
272            .sum::<usize>();
273        let general_work = model.graph().nodes().len().max(1);
274        if execution.normalization_mode() == NormalizationMode::Auto
275            && basis_work > general_work.saturating_mul(AUTO_BREAK_EVEN_EVALUATIONS)
276        {
277            return Ok(None);
278        }
279
280        let statistic_bytes = if execution.precision() == crate::Precision::F32 {
281            std::mem::size_of::<Complex32>()
282        } else {
283            std::mem::size_of::<Complex64>()
284        };
285        let retained_bytes = basis_models.len().saturating_mul(statistic_bytes);
286        let memory_lease = match execution
287            .host_memory()
288            .reserve(u64::try_from(retained_bytes).unwrap_or(u64::MAX))
289        {
290            Ok(lease) => lease,
291            Err(_) if execution.normalization_mode() == NormalizationMode::Auto => return Ok(None),
292            Err(error) => return Err(error.into()),
293        };
294        let basis_plans = basis_models
295            .iter()
296            .map(|basis| {
297                CpuBackend
298                    .prepare_with_autodiff_mode(basis, execution.autodiff_mode())
299                    .map_err(|error| RuntimeError::Data(error.to_string()))
300            })
301            .collect::<RuntimeResult<Vec<_>>>()?;
302        let basis_params = basis_models
303            .iter()
304            .map(|basis| basis.params().default_values())
305            .collect::<Vec<_>>();
306        let (statistics, stats) =
307            accumulate_statistics(&basis_plans, &basis_params, dataset, execution)?;
308        let statistics = StoredStatistics::from_f64(statistics, execution.precision());
309        let evaluator_statistics = statistics.evaluator_values();
310        let evaluator_model = model
311            .normalization_plan()
312            .evaluator_model(&evaluator_statistics)
313            .map_err(|error| RuntimeError::Data(error.to_string()))?;
314        // Sufficient statistics may be prepared for an f32 accelerator, but
315        // their tiny parameter-only contraction stays on the CPU in f64 so
316        // value/gradient evaluation remains available and numerically stable.
317        let evaluator = CpuBackend
318            .prepare_with_autodiff_mode(&evaluator_model, execution.autodiff_mode())
319            .map_err(|error| RuntimeError::Data(error.to_string()))?;
320        let evaluator_parameters = ParameterMapping::new(evaluator_model.params(), model.params())?;
321
322        let residual_model = model
323            .normalization_plan()
324            .residual_model()
325            .map_err(|error| RuntimeError::Data(error.to_string()))?;
326        let residual = if let Some(residual_model) = residual_model {
327            let parameters = ParameterMapping::new(residual_model.params(), model.params())?;
328            let plan = PreparedModel::prepare(&residual_model, execution)?;
329            let dataset = plan.prepare_dataset(execution, dataset)?;
330            Some(GeneralResidual {
331                plan,
332                dataset,
333                parameters,
334            })
335        } else {
336            None
337        };
338        let verification = if execution.normalization_mode() == NormalizationMode::Verify {
339            Some(GeneralResidual {
340                plan: general_plan.clone(),
341                dataset: general_plan.prepare_dataset(execution, dataset)?,
342                parameters: ParameterMapping::new(model.params(), model.params())?,
343            })
344        } else {
345            None
346        };
347        let preparation_passes =
348            1 + usize::from(residual.is_some()) + usize::from(verification.is_some());
349        Ok(Some(Self {
350            evaluator,
351            evaluator_parameters,
352            statistics,
353            residual,
354            verification,
355            stats,
356            diagnostics: PreparedNormalizationDiagnostics {
357                strategy: model.normalization_diagnostics().strategy(),
358                compiler: model.normalization_diagnostics().clone(),
359                retained_bytes,
360                preparation_passes,
361                cache_hit: false,
362                tag_projection_reused_parent: false,
363            },
364            cache_reused: AtomicBool::new(false),
365            _memory_lease: memory_lease,
366        }))
367    }
368
369    /// Returns accepted-dataset statistics collected during preparation.
370    pub fn stats(&self) -> &PreparedDatasetStats {
371        &self.stats
372    }
373
374    /// Returns normalization preparation diagnostics.
375    pub fn diagnostics(&self) -> PreparedNormalizationDiagnostics {
376        let mut diagnostics = self.diagnostics.clone();
377        diagnostics.cache_hit = self.cache_reused.load(Ordering::Relaxed);
378        diagnostics
379    }
380
381    /// Returns retained sufficient-statistic storage in bytes.
382    pub fn resident_bytes(&self) -> usize {
383        self.statistics.resident_bytes()
384    }
385
386    /// Evaluates the accepted normalization without constructing a gradient.
387    ///
388    /// # Errors
389    ///
390    /// Returns a runtime error for incompatible parameters, residual backend
391    /// failures, or a verification mismatch.
392    pub fn value(&self, params: &ParamValues, execution: &Execution) -> RuntimeResult<f64> {
393        let evaluator_params = self.evaluator_parameters.project(params)?;
394        let mut value = self.evaluator.evaluate(&evaluator_params)?.re;
395        if let Some(residual) = &self.residual {
396            let residual_params = residual.parameters.project(params)?;
397            value += residual.plan.reduce(
398                execution,
399                &residual_params,
400                &residual.dataset,
401                ReductionPlan::weighted_real(),
402            )?;
403        }
404        if let Some(general) = &self.verification {
405            let general_params = general.parameters.project(params)?;
406            let expected = general.plan.reduce(
407                execution,
408                &general_params,
409                &general.dataset,
410                ReductionPlan::weighted_real(),
411            )?;
412            verify_close("normalization value", value, expected, execution)?;
413        }
414        Ok(value)
415    }
416
417    /// Evaluates the accepted normalization and its local free-parameter gradient.
418    ///
419    /// # Errors
420    ///
421    /// Returns a runtime error for incompatible parameters, autodiff/backend
422    /// failures, or a verification mismatch.
423    pub fn value_gradient(
424        &self,
425        params: &ParamValues,
426        execution: &Execution,
427    ) -> RuntimeResult<(f64, Vec<f64>)> {
428        let evaluator_params = self.evaluator_parameters.project(params)?;
429        let evaluation = self.evaluator.evaluate_with_gradient(&evaluator_params)?;
430        let mut value = evaluation.value().re;
431        let evaluator_gradient = evaluation
432            .gradient()
433            .iter()
434            .map(|value| value.re)
435            .collect::<Vec<_>>();
436        let mut gradient = vec![0.0; self.evaluator_parameters.parent_free];
437        self.evaluator_parameters
438            .scatter_add(&evaluator_gradient, &mut gradient)?;
439        if let Some(residual) = &self.residual {
440            let residual_params = residual.parameters.project(params)?;
441            let residual_evaluation = residual.plan.reduce_with_gradient(
442                execution,
443                &residual_params,
444                &residual.dataset,
445                ReductionPlan::weighted_real(),
446            )?;
447            value += residual_evaluation.value();
448            residual
449                .parameters
450                .scatter_add(residual_evaluation.gradient(), &mut gradient)?;
451        }
452        if let Some(general) = &self.verification {
453            let general_params = general.parameters.project(params)?;
454            let expected = general.plan.reduce_with_gradient(
455                execution,
456                &general_params,
457                &general.dataset,
458                ReductionPlan::weighted_real(),
459            )?;
460            verify_close("normalization value", value, expected.value(), execution)?;
461            for (index, (actual, expected)) in gradient.iter().zip(expected.gradient()).enumerate()
462            {
463                verify_close(
464                    &format!("normalization gradient[{index}]"),
465                    *actual,
466                    *expected,
467                    execution,
468                )?;
469            }
470        }
471        Ok((value, gradient))
472    }
473}
474
475fn accumulate_statistics(
476    plans: &[CpuPlan],
477    params: &[ParamValues],
478    dataset: &Dataset,
479    execution: &Execution,
480) -> RuntimeResult<(Vec<Complex64>, PreparedDatasetStats)> {
481    let mut sums = vec![Complex64::ZERO; plans.len()];
482    let mut corrections = vec![Complex64::ZERO; plans.len()];
483    let mut read_plan: ReadPlan = execution.read_plan(dataset.read_plan());
484    let local_limit = dataset
485        .num_events()
486        .map_err(|error| RuntimeError::Data(error.to_string()))?
487        .and_then(|events| usize::try_from(events).ok())
488        .unwrap_or(usize::MAX);
489    let statistic_bytes = plans.len().saturating_mul(std::mem::size_of::<Complex64>());
490    let decision = MemoryFitRequest {
491        label: "normalization statistics".into(),
492        footprint: MemoryFootprint::from_usize(statistic_bytes, statistic_bytes),
493        available_bytes: execution.host_memory().remaining(),
494        event_limit: local_limit,
495        strategy: "single-pass sufficient statistics".into(),
496    }
497    .evaluate()?;
498    read_plan.chunk_size = Some(
499        read_plan
500            .chunk_size
501            .map_or(decision.chunk_events, |manual| {
502                manual.min(decision.chunk_events)
503            })
504            .max(1),
505    );
506    execution.record_memory_decision(decision);
507    let local = (|| {
508        let mut events = 0usize;
509        let mut batches = 0usize;
510        let mut weight_sum = 0.0;
511        let mut weight_correction = 0.0;
512        for batch in dataset
513            .stream_with_plan(read_plan)
514            .map_err(|error| RuntimeError::Data(error.to_string()))?
515        {
516            let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
517            events += batch.len();
518            batches += 1;
519            for row in 0..batch.len() {
520                let weight = batch.weights_at(row);
521                let corrected = weight - weight_correction;
522                let next = weight_sum + corrected;
523                weight_correction = (next - weight_sum) - corrected;
524                weight_sum = next;
525            }
526            for (index, (plan, params)) in plans.iter().zip(params).enumerate() {
527                for (row, value) in plan.evaluate_batch(params, &batch)?.into_iter().enumerate() {
528                    let value = value * batch.weights_at(row);
529                    let corrected = value - corrections[index];
530                    let next = sums[index] + corrected;
531                    corrections[index] = (next - sums[index]) - corrected;
532                    sums[index] = next;
533                }
534            }
535        }
536        Ok::<_, RuntimeError>((events, batches, weight_sum))
537    })();
538    if !execution.all_succeeded(local.is_ok()) {
539        return local.and(Err(RuntimeError::DistributedPeerFailure));
540    }
541    let (events, batches, weight_sum) = local?;
542    for sum in &mut sums {
543        sum.re = execution.sum_f64(sum.re);
544        sum.im = execution.sum_f64(sum.im);
545    }
546    let stats = PreparedDatasetStats::new(
547        events,
548        execution.sum_usize(events),
549        batches,
550        execution.sum_f64(weight_sum),
551        sums.len() * std::mem::size_of::<Complex64>(),
552        CacheStorage::Resident,
553    );
554    Ok((sums, stats))
555}
556
557fn verify_close(
558    label: &str,
559    actual: f64,
560    expected: f64,
561    execution: &Execution,
562) -> RuntimeResult<()> {
563    let tolerance = match execution.precision() {
564        crate::Precision::F32 => 5.0e-4,
565        crate::Precision::Auto | crate::Precision::F64 => 1.0e-10,
566    } * expected.abs().max(1.0);
567    if (actual - expected).abs() <= tolerance {
568        Ok(())
569    } else {
570        Err(RuntimeError::Data(format!(
571            "{label} verification failed: compiler-native={actual}, general={expected}, tolerance={tolerance}"
572        )))
573    }
574}