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
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        // Sufficient statistics may be prepared for an f32 accelerator, but
271        // their tiny parameter-only contraction stays on the CPU in f64 so
272        // value/gradient evaluation remains available and numerically stable.
273        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    /// Returns accepted-dataset statistics collected during preparation.
327    pub fn stats(&self) -> &PreparedDatasetStats {
328        &self.stats
329    }
330
331    /// Returns normalization preparation diagnostics.
332    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    /// Returns retained sufficient-statistic storage in bytes.
339    pub fn resident_bytes(&self) -> usize {
340        self.statistics.resident_bytes()
341    }
342
343    /// Evaluates the accepted normalization without constructing a gradient.
344    ///
345    /// # Errors
346    ///
347    /// Returns a runtime error for incompatible parameters, residual backend
348    /// failures, or a verification mismatch.
349    pub fn value(&self, params: &ParamValues, execution: &Execution) -> RuntimeResult<f64> {
350        Ok(self.evaluate_composed(params, execution, false)?.value)
351    }
352
353    /// Evaluates the accepted normalization and its local free-parameter gradient.
354    ///
355    /// # Errors
356    ///
357    /// Returns a runtime error for incompatible parameters, autodiff/backend
358    /// failures, or a verification mismatch.
359    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}