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