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::{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/// 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: 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/// Prepared sufficient statistics and their parameter-only contraction.
152#[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    /// Prepares compiler-native normalization when selected by execution policy.
173    ///
174    /// # Errors
175    ///
176    /// Returns a runtime error when basis compilation, dataset traversal,
177    /// memory reservation, or backend preparation fails.
178    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.prepare_shared_with_autodiff_mode(basis, execution.autodiff_mode())
254            })
255            .collect::<RuntimeResult<Vec<_>>>()?;
256        let basis_params = basis_models
257            .iter()
258            .map(|basis| basis.params().default_values())
259            .collect::<Vec<_>>();
260        let (statistics, stats) =
261            accumulate_statistics(&basis_plans, &basis_params, dataset, execution)?;
262        let statistics = StoredStatistics::from_f64(statistics, execution.precision());
263        let evaluator_statistics = statistics.evaluator_values();
264        let evaluator_model = model
265            .normalization_plan()
266            .evaluator_model(&evaluator_statistics)
267            .map_err(|error| RuntimeError::Data(error.to_string()))?;
268        // Sufficient statistics may be prepared for an f32 accelerator, but
269        // their tiny parameter-only contraction stays on the CPU in f64 so
270        // value/gradient evaluation remains available and numerically stable.
271        let evaluator = CpuBackend
272            .prepare_with_autodiff_mode(&evaluator_model, execution.autodiff_mode())
273            .map_err(|error| RuntimeError::Data(error.to_string()))?;
274        let evaluator_parameters =
275            normalization_projection(evaluator_model.params(), model.params())?;
276
277        let residual_model = model
278            .normalization_plan()
279            .residual_model()
280            .map_err(|error| RuntimeError::Data(error.to_string()))?;
281        let residual = if let Some(residual_model) = residual_model {
282            let parameters = normalization_projection(residual_model.params(), model.params())?;
283            let plan = PreparedModel::prepare(&residual_model, execution)?;
284            let dataset = plan.prepare_dataset(execution, dataset)?;
285            Some(GeneralResidual {
286                plan,
287                dataset,
288                parameters,
289            })
290        } else {
291            None
292        };
293        let verification = if execution.normalization_mode() == NormalizationMode::Verify {
294            Some(GeneralResidual {
295                plan: general_plan.clone(),
296                dataset: general_plan.prepare_dataset(execution, dataset)?,
297                parameters: normalization_projection(model.params(), model.params())?,
298            })
299        } else {
300            None
301        };
302        let preparation_passes =
303            1 + usize::from(residual.is_some()) + usize::from(verification.is_some());
304        Ok(Some(Self {
305            evaluator,
306            evaluator_parameters,
307            statistics,
308            residual,
309            verification,
310            stats,
311            diagnostics: PreparedNormalizationDiagnostics {
312                strategy: model.normalization_diagnostics().strategy(),
313                compiler: model.normalization_diagnostics().clone(),
314                retained_bytes,
315                preparation_passes,
316                cache_hit: false,
317                tag_projection_reused_parent: false,
318            },
319            cache_reused: AtomicBool::new(false),
320            _memory_lease: memory_lease,
321        }))
322    }
323
324    /// Returns accepted-dataset statistics collected during preparation.
325    pub fn stats(&self) -> &PreparedDatasetStats {
326        &self.stats
327    }
328
329    /// Returns normalization preparation diagnostics.
330    pub fn diagnostics(&self) -> PreparedNormalizationDiagnostics {
331        let mut diagnostics = self.diagnostics.clone();
332        diagnostics.cache_hit = self.cache_reused.load(Ordering::Relaxed);
333        diagnostics
334    }
335
336    /// Returns retained sufficient-statistic storage in bytes.
337    pub fn resident_bytes(&self) -> usize {
338        self.statistics.resident_bytes()
339    }
340
341    /// Evaluates the accepted normalization without constructing a gradient.
342    ///
343    /// # Errors
344    ///
345    /// Returns a runtime error for incompatible parameters, residual backend
346    /// failures, or a verification mismatch.
347    pub fn value(&self, params: &ParamValues, execution: &Execution) -> RuntimeResult<f64> {
348        Ok(self.evaluate_composed(params, execution, false)?.value)
349    }
350
351    /// Evaluates the accepted normalization and its local free-parameter gradient.
352    ///
353    /// # Errors
354    ///
355    /// Returns a runtime error for incompatible parameters, autodiff/backend
356    /// failures, or a verification mismatch.
357    pub fn value_gradient(
358        &self,
359        params: &ParamValues,
360        execution: &Execution,
361    ) -> RuntimeResult<(f64, Vec<f64>)> {
362        let evaluation = self.evaluate_composed(params, execution, true)?;
363        Ok((
364            evaluation.value,
365            evaluation.gradient.ok_or_else(|| {
366                RuntimeError::Data("normalization gradient composition produced no gradient".into())
367            })?,
368        ))
369    }
370
371    fn evaluate_composed(
372        &self,
373        params: &ParamValues,
374        execution: &Execution,
375        with_gradient: bool,
376    ) -> RuntimeResult<NormalizationEvaluation> {
377        let evaluator_params = project_normalization(&self.evaluator_parameters, params)?;
378        let (mut value, mut gradient) = if with_gradient {
379            let evaluation = self.evaluator.evaluate_with_gradient(&evaluator_params)?;
380            let mut gradient = vec![0.0; params.layout().n_free()];
381            let evaluator_gradient = evaluation
382                .gradient()
383                .iter()
384                .map(|value| value.re)
385                .collect::<Vec<_>>();
386            self.evaluator_parameters
387                .scatter_add(&evaluator_gradient, &mut gradient)
388                .map_err(|_| incompatible_gradient_layout())?;
389            (evaluation.value().re, Some(gradient))
390        } else {
391            (self.evaluator.evaluate(&evaluator_params)?.re, None)
392        };
393        if let Some(residual) = &self.residual {
394            let residual_params = project_normalization(&residual.parameters, params)?;
395            if let Some(gradient) = &mut gradient {
396                let residual_evaluation = residual.plan.reduce_with_gradient(
397                    execution,
398                    &residual_params,
399                    &residual.dataset,
400                    ReductionPlan::weighted_real(),
401                )?;
402                value += residual_evaluation.value();
403                residual
404                    .parameters
405                    .scatter_add(residual_evaluation.gradient(), gradient)
406                    .map_err(|_| incompatible_gradient_layout())?;
407            } else {
408                value += residual.plan.reduce(
409                    execution,
410                    &residual_params,
411                    &residual.dataset,
412                    ReductionPlan::weighted_real(),
413                )?;
414            }
415        }
416        if let Some(general) = &self.verification {
417            let general_params = project_normalization(&general.parameters, params)?;
418            if let Some(gradient) = &gradient {
419                let expected = general.plan.reduce_with_gradient(
420                    execution,
421                    &general_params,
422                    &general.dataset,
423                    ReductionPlan::weighted_real(),
424                )?;
425                verify_close("normalization value", value, expected.value(), execution)?;
426                for (index, (actual, expected)) in
427                    gradient.iter().zip(expected.gradient()).enumerate()
428                {
429                    verify_close(
430                        &format!("normalization gradient[{index}]"),
431                        *actual,
432                        *expected,
433                        execution,
434                    )?;
435                }
436            } else {
437                let expected = general.plan.reduce(
438                    execution,
439                    &general_params,
440                    &general.dataset,
441                    ReductionPlan::weighted_real(),
442                )?;
443                verify_close("normalization value", value, expected, execution)?;
444            }
445        }
446        Ok(NormalizationEvaluation { value, gradient })
447    }
448}
449
450fn incompatible_gradient_layout() -> RuntimeError {
451    RuntimeError::Data("normalization gradient has an incompatible parameter layout".into())
452}
453
454fn accumulate_statistics(
455    plans: &[Arc<CpuPlan>],
456    params: &[ParamValues],
457    dataset: &Dataset,
458    execution: &Execution,
459) -> RuntimeResult<(Vec<Complex64>, PreparedDatasetStats)> {
460    let mut sums = vec![Complex64::ZERO; plans.len()];
461    let mut corrections = vec![Complex64::ZERO; plans.len()];
462    let mut read_plan: ReadPlan = execution.read_plan(dataset.read_plan());
463    let local_limit = dataset
464        .num_events()
465        .map_err(|error| RuntimeError::Data(error.to_string()))?
466        .and_then(|events| usize::try_from(events).ok())
467        .unwrap_or(usize::MAX);
468    let statistic_bytes = plans.len().saturating_mul(std::mem::size_of::<Complex64>());
469    let decision = MemoryFitRequest {
470        label: "normalization statistics".into(),
471        footprint: MemoryFootprint::from_usize(statistic_bytes, statistic_bytes),
472        available_bytes: execution.host_memory().remaining(),
473        event_limit: local_limit,
474        strategy: "single-pass sufficient statistics".into(),
475    }
476    .evaluate()?;
477    read_plan.chunk_size = Some(
478        read_plan
479            .chunk_size
480            .map_or(decision.chunk_events, |manual| {
481                manual.min(decision.chunk_events)
482            })
483            .max(1),
484    );
485    execution.record_memory_decision(decision);
486    let local = (|| {
487        let mut events = 0usize;
488        let mut batches = 0usize;
489        let mut weight_sum = 0.0;
490        let mut weight_correction = 0.0;
491        for batch in dataset
492            .stream_with_plan(read_plan)
493            .map_err(|error| RuntimeError::Data(error.to_string()))?
494        {
495            let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
496            events += batch.len();
497            batches += 1;
498            for row in 0..batch.len() {
499                let weight = batch.weights_at(row);
500                let corrected = weight - weight_correction;
501                let next = weight_sum + corrected;
502                weight_correction = (next - weight_sum) - corrected;
503                weight_sum = next;
504            }
505            for (index, (plan, params)) in plans.iter().zip(params).enumerate() {
506                for (row, value) in plan.evaluate_batch(params, &batch)?.into_iter().enumerate() {
507                    let value = value * batch.weights_at(row);
508                    let corrected = value - corrections[index];
509                    let next = sums[index] + corrected;
510                    corrections[index] = (next - sums[index]) - corrected;
511                    sums[index] = next;
512                }
513            }
514        }
515        Ok::<_, RuntimeError>((events, batches, weight_sum))
516    })();
517    if !execution.all_succeeded(local.is_ok()) {
518        return local.and(Err(RuntimeError::DistributedPeerFailure));
519    }
520    let (events, batches, weight_sum) = local?;
521    for sum in &mut sums {
522        sum.re = execution.sum_f64(sum.re);
523        sum.im = execution.sum_f64(sum.im);
524    }
525    let stats = PreparedDatasetStats::new(
526        events,
527        execution.sum_usize(events),
528        batches,
529        execution.sum_f64(weight_sum),
530        sums.len() * std::mem::size_of::<Complex64>(),
531        CacheStorage::Resident,
532    );
533    Ok((sums, stats))
534}
535
536fn verify_close(
537    label: &str,
538    actual: f64,
539    expected: f64,
540    execution: &Execution,
541) -> RuntimeResult<()> {
542    let tolerance = match execution.precision() {
543        crate::Precision::F32 => 5.0e-4,
544        crate::Precision::Auto | crate::Precision::F64 => 1.0e-10,
545    } * expected.abs().max(1.0);
546    if (actual - expected).abs() <= tolerance {
547        Ok(())
548    } else {
549        Err(RuntimeError::Data(format!(
550            "{label} verification failed: compiler-native={actual}, general={expected}, tolerance={tolerance}"
551        )))
552    }
553}