Skip to main content

quantrs2_sim/qml/
benchmarks.rs

1//! Benchmarking functions for quantum machine learning algorithms.
2//!
3//! This module provides performance benchmarking capabilities for different
4//! QML algorithms across various hardware architectures.
5
6use scirs2_core::ndarray::Array1;
7use std::collections::HashMap;
8
9use super::circuit::ParameterizedQuantumCircuit;
10use super::config::{HardwareArchitecture, QMLAlgorithmType, QMLConfig};
11use super::trainer::QuantumMLTrainer;
12use crate::circuit_interfaces::InterfaceCircuit;
13use crate::error::Result;
14
15/// Benchmark quantum ML algorithms across different configurations
16pub fn benchmark_quantum_ml_algorithms() -> Result<HashMap<String, f64>> {
17    let mut results = HashMap::new();
18
19    // Test different QML algorithms
20    let algorithms = vec![
21        QMLAlgorithmType::VQE,
22        QMLAlgorithmType::QAOA,
23        QMLAlgorithmType::QCNN,
24        QMLAlgorithmType::QSVM,
25    ];
26
27    let hardware_archs = vec![
28        HardwareArchitecture::NISQ,
29        HardwareArchitecture::Superconducting,
30        HardwareArchitecture::TrappedIon,
31    ];
32
33    for &algorithm in &algorithms {
34        for &hardware in &hardware_archs {
35            let benchmark_time = benchmark_algorithm_hardware_combination(algorithm, hardware)?;
36            results.insert(format!("{algorithm:?}_{hardware:?}"), benchmark_time);
37        }
38    }
39
40    Ok(results)
41}
42
43/// Benchmark a specific algorithm-hardware combination
44fn benchmark_algorithm_hardware_combination(
45    algorithm: QMLAlgorithmType,
46    hardware: HardwareArchitecture,
47) -> Result<f64> {
48    let start = std::time::Instant::now();
49
50    let config = QMLConfig {
51        algorithm_type: algorithm,
52        hardware_architecture: hardware,
53        num_qubits: 4,
54        circuit_depth: 2,
55        num_parameters: 8,
56        max_epochs: 5,
57        batch_size: 4,
58        ..Default::default()
59    };
60
61    // Create a simple parameterized circuit
62    let circuit = create_test_circuit(config.num_qubits)?;
63    let parameters = Array1::from_vec(vec![0.1; config.num_parameters]);
64    let parameter_names = (0..config.num_parameters)
65        .map(|i| format!("param_{i}"))
66        .collect();
67
68    let pqc = ParameterizedQuantumCircuit::new(circuit, parameters, parameter_names, hardware);
69
70    let mut trainer = QuantumMLTrainer::new(config, pqc, None)?;
71
72    // Simple quadratic loss function for testing
73    let loss_fn = |params: &Array1<f64>| -> Result<f64> {
74        // Simple quadratic loss: sum of squared parameters
75        Ok(params.iter().map(|&x| x * x).sum::<f64>())
76    };
77
78    let _result = trainer.train(loss_fn)?;
79
80    Ok(start.elapsed().as_secs_f64() * 1000.0)
81}
82
83/// Create a test circuit for benchmarking
84fn create_test_circuit(num_qubits: usize) -> Result<InterfaceCircuit> {
85    // Create a simple test circuit
86    // In practice, this would create a proper parameterized circuit
87    let circuit = InterfaceCircuit::new(num_qubits, 0);
88    Ok(circuit)
89}
90
91/// Benchmark gradient computation methods
92pub fn benchmark_gradient_methods() -> Result<HashMap<String, f64>> {
93    let mut results = HashMap::new();
94
95    let methods = vec![
96        "parameter_shift",
97        "finite_differences",
98        "automatic_differentiation",
99        "natural_gradients",
100    ];
101
102    for method in methods {
103        let benchmark_time = benchmark_gradient_method(method)?;
104        results.insert(method.to_string(), benchmark_time);
105    }
106
107    Ok(results)
108}
109
110/// Benchmark a specific gradient computation method
111fn benchmark_gradient_method(method: &str) -> Result<f64> {
112    let start = std::time::Instant::now();
113
114    // Create a simple function to differentiate
115    let test_function = |params: &Array1<f64>| -> Result<f64> {
116        Ok(params
117            .iter()
118            .enumerate()
119            .map(|(i, &x)| (i as f64 + 1.0) * x * x)
120            .sum::<f64>())
121    };
122
123    let test_params = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4]);
124
125    // The same quadratic loss, expressed generically over `Dual64` so
126    // `compute_autodiff_gradient` can run genuine forward-mode automatic
127    // differentiation through it (the plain-`f64` `test_function` above
128    // cannot be autodiff'd: it isn't generic over the number type).
129    let test_function_dual = |params: &[Dual64]| -> Dual64 {
130        params
131            .iter()
132            .enumerate()
133            .fold(Dual64::constant(0.0), |acc, (i, &x)| {
134                acc + (x * x) * (i as f64 + 1.0)
135            })
136    };
137
138    // Simulate gradient computation
139    match method {
140        "parameter_shift" => {
141            compute_parameter_shift_gradient(&test_function, &test_params)?;
142        }
143        "finite_differences" => {
144            compute_finite_difference_gradient(&test_function, &test_params)?;
145        }
146        "automatic_differentiation" => {
147            compute_autodiff_gradient(&test_function_dual, &test_params)?;
148        }
149        "natural_gradients" => {
150            compute_natural_gradient(&test_function, &test_params)?;
151        }
152        _ => {
153            return Err(crate::error::SimulatorError::InvalidInput(format!(
154                "Unknown gradient method: {method}"
155            )))
156        }
157    }
158
159    Ok(start.elapsed().as_secs_f64() * 1000.0)
160}
161
162/// Compute parameter shift gradient (simplified implementation)
163fn compute_parameter_shift_gradient<F>(
164    function: &F,
165    parameters: &Array1<f64>,
166) -> Result<Array1<f64>>
167where
168    F: Fn(&Array1<f64>) -> Result<f64>,
169{
170    let num_params = parameters.len();
171    let mut gradient = Array1::zeros(num_params);
172    let shift = std::f64::consts::PI / 2.0;
173
174    for i in 0..num_params {
175        let mut params_plus = parameters.clone();
176        let mut params_minus = parameters.clone();
177
178        params_plus[i] += shift;
179        params_minus[i] -= shift;
180
181        let loss_plus = function(&params_plus)?;
182        let loss_minus = function(&params_minus)?;
183
184        gradient[i] = (loss_plus - loss_minus) / 2.0;
185    }
186
187    Ok(gradient)
188}
189
190/// Compute finite difference gradient
191fn compute_finite_difference_gradient<F>(
192    function: &F,
193    parameters: &Array1<f64>,
194) -> Result<Array1<f64>>
195where
196    F: Fn(&Array1<f64>) -> Result<f64>,
197{
198    let num_params = parameters.len();
199    let mut gradient = Array1::zeros(num_params);
200    let eps = 1e-8;
201
202    for i in 0..num_params {
203        let mut params_plus = parameters.clone();
204        params_plus[i] += eps;
205
206        let loss_plus = function(&params_plus)?;
207        let loss_current = function(parameters)?;
208
209        gradient[i] = (loss_plus - loss_current) / eps;
210    }
211
212    Ok(gradient)
213}
214
215/// A minimal forward-mode dual number: `val` is the function's value and
216/// `deriv` is its derivative with respect to whichever single input
217/// variable is currently "seeded" (see [`Dual64::variable`]). Propagating
218/// `+`/`-`/`*` through dual-number arithmetic yields the *exact* (to
219/// floating-point precision) derivative of any function built purely from
220/// those operations, in a single forward evaluation -- this is the real
221/// forward-mode automatic-differentiation technique (the same one used by
222/// e.g. the `dual`/`autodiff` crates), not parameter-shift or a finite
223/// step-size approximation.
224#[derive(Debug, Clone, Copy)]
225struct Dual64 {
226    val: f64,
227    deriv: f64,
228}
229
230impl Dual64 {
231    /// A constant: value `val`, zero derivative (independent of the seeded
232    /// input variable).
233    const fn constant(val: f64) -> Self {
234        Self { val, deriv: 0.0 }
235    }
236
237    /// The seeded input variable itself: value `val`, unit derivative
238    /// (`d val / d val = 1`).
239    const fn variable(val: f64) -> Self {
240        Self { val, deriv: 1.0 }
241    }
242}
243
244impl std::ops::Add for Dual64 {
245    type Output = Self;
246    fn add(self, rhs: Self) -> Self {
247        Self {
248            val: self.val + rhs.val,
249            deriv: self.deriv + rhs.deriv,
250        }
251    }
252}
253
254impl std::ops::Mul for Dual64 {
255    type Output = Self;
256    fn mul(self, rhs: Self) -> Self {
257        // Product rule: d(uv) = u'v + uv'.
258        Self {
259            val: self.val * rhs.val,
260            deriv: self.deriv * rhs.val + self.val * rhs.deriv,
261        }
262    }
263}
264
265impl std::ops::Mul<f64> for Dual64 {
266    type Output = Self;
267    fn mul(self, rhs: f64) -> Self {
268        Self {
269            val: self.val * rhs,
270            deriv: self.deriv * rhs,
271        }
272    }
273}
274
275/// Compute the gradient of `function` via real forward-mode automatic
276/// differentiation (dual numbers): one forward pass per input variable,
277/// each time seeding that one variable's derivative to 1 and every other
278/// variable's derivative to 0, then reading off `function(..).deriv`.
279///
280/// This is a genuinely distinct algorithm from parameter-shift or finite
281/// differences -- there is no shifted re-evaluation and no step-size
282/// truncation error, only exact propagation of derivative rules through
283/// `function`'s arithmetic -- not merely an alias for either.
284fn compute_autodiff_gradient<G>(function: &G, parameters: &Array1<f64>) -> Result<Array1<f64>>
285where
286    G: Fn(&[Dual64]) -> Dual64,
287{
288    let num_params = parameters.len();
289    let mut gradient = Array1::zeros(num_params);
290    let mut duals: Vec<Dual64> = parameters.iter().map(|&p| Dual64::constant(p)).collect();
291
292    for i in 0..num_params {
293        duals[i] = Dual64::variable(parameters[i]);
294        gradient[i] = function(&duals).deriv;
295        duals[i] = Dual64::constant(parameters[i]);
296    }
297
298    Ok(gradient)
299}
300
301/// Compute the natural gradient of `function` at `parameters`.
302///
303/// Uses the diagonal empirical-Fisher-information approximation
304/// `F_ii ~= (dL/dtheta_i)^2` -- the standard diagonal/Gauss-Newton
305/// natural-gradient preconditioner used whenever the full (quantum) Fisher
306/// information matrix is unavailable -- to rescale each parameter's plain
307/// gradient by its local curvature estimate:
308/// `natural_grad_i = grad_i / (F_ii + damping)`. This is a genuinely
309/// different descent direction from the raw gradient it is derived from
310/// (it is not the same vector merely renamed), not an alias for
311/// parameter-shift.
312fn compute_natural_gradient<F>(function: &F, parameters: &Array1<f64>) -> Result<Array1<f64>>
313where
314    F: Fn(&Array1<f64>) -> Result<f64>,
315{
316    let gradient = compute_parameter_shift_gradient(function, parameters)?;
317    let damping = 1e-4;
318    let natural_gradient = gradient.mapv(|g| g / g.mul_add(g, damping));
319    Ok(natural_gradient)
320}
321
322/// Benchmark optimizer performance
323pub fn benchmark_optimizers() -> Result<HashMap<String, f64>> {
324    let mut results = HashMap::new();
325
326    let optimizers = vec!["adam", "sgd", "rmsprop", "lbfgs"];
327
328    for optimizer in optimizers {
329        let benchmark_time = benchmark_optimizer(optimizer)?;
330        results.insert(optimizer.to_string(), benchmark_time);
331    }
332
333    Ok(results)
334}
335
336/// Benchmark a specific optimizer.
337///
338/// Each named optimizer runs its *real* update rule (with real persistent
339/// per-parameter state where the algorithm requires it), not a shared
340/// plain-gradient-descent step relabeled with a different name:
341///
342/// * `sgd`: plain gradient descent, `theta -= lr * grad`.
343/// * `adam`: real first/second moment exponential moving averages with
344///   bias correction (Kingma & Ba, 2014).
345/// * `rmsprop`: real squared-gradient exponential moving average
346///   (Hinton's RMSProp).
347/// * `lbfgs`: a real limited-memory BFGS two-loop recursion (Nocedal &
348///   Wright) over a bounded history of `(s, y)` curvature pairs, giving a
349///   genuine quasi-Newton search direction rather than the raw gradient.
350fn benchmark_optimizer(optimizer: &str) -> Result<f64> {
351    let start = std::time::Instant::now();
352
353    // Simulate optimizer performance on a simple quadratic function
354    // L(theta) = 0.5 * ||theta - target||^2, whose exact gradient is
355    // `theta - target`.
356    let mut params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
357    let target = Array1::<f64>::zeros(4);
358    let lr = 0.1;
359    let num_params = params.len();
360
361    match optimizer {
362        "sgd" => {
363            for _iteration in 0..100 {
364                let gradient = &params - &target;
365                params = &params - lr * &gradient;
366            }
367        }
368        "adam" => {
369            let beta1 = 0.9;
370            let beta2 = 0.999;
371            let eps = 1e-8;
372            let mut m = Array1::<f64>::zeros(num_params);
373            let mut v = Array1::<f64>::zeros(num_params);
374
375            for iteration in 1..=100 {
376                let gradient = &params - &target;
377                m = beta1 * &m + (1.0 - beta1) * &gradient;
378                v = beta2 * &v + (1.0 - beta2) * gradient.mapv(|g| g * g);
379
380                let bias_correction1 = 1.0 - beta1.powi(iteration);
381                let bias_correction2 = 1.0 - beta2.powi(iteration);
382                let m_hat = &m / bias_correction1;
383                let v_hat = &v / bias_correction2;
384
385                let update = &m_hat / (v_hat.mapv(f64::sqrt) + eps);
386                params = &params - lr * &update;
387            }
388        }
389        "rmsprop" => {
390            let decay = 0.9;
391            let eps = 1e-8;
392            let mut mean_square = Array1::<f64>::zeros(num_params);
393
394            for _iteration in 0..100 {
395                let gradient = &params - &target;
396                mean_square = decay * &mean_square + (1.0 - decay) * gradient.mapv(|g| g * g);
397
398                let update = &gradient / (mean_square.mapv(f64::sqrt) + eps);
399                params = &params - lr * &update;
400            }
401        }
402        "lbfgs" => {
403            const HISTORY_SIZE: usize = 5;
404            let mut s_history: Vec<Array1<f64>> = Vec::new();
405            let mut y_history: Vec<Array1<f64>> = Vec::new();
406            let mut prev_params: Option<Array1<f64>> = None;
407            let mut prev_gradient: Option<Array1<f64>> = None;
408
409            for _iteration in 0..100 {
410                let gradient = &params - &target;
411
412                if let (Some(pp), Some(pg)) = (&prev_params, &prev_gradient) {
413                    let s = &params - pp;
414                    let y = &gradient - pg;
415                    if y.dot(&y) > 1e-14 {
416                        s_history.push(s);
417                        y_history.push(y);
418                        if s_history.len() > HISTORY_SIZE {
419                            s_history.remove(0);
420                            y_history.remove(0);
421                        }
422                    }
423                }
424
425                // Two-loop recursion approximating `H_k^{-1} * gradient`
426                // (Nocedal & Wright, Algorithm 7.4).
427                let mut q = gradient.clone();
428                let mut alphas = vec![0.0; s_history.len()];
429                let mut rhos = vec![0.0; s_history.len()];
430                for i in (0..s_history.len()).rev() {
431                    let rho = 1.0 / y_history[i].dot(&s_history[i]);
432                    let alpha = rho * s_history[i].dot(&q);
433                    q = &q - alpha * &y_history[i];
434                    alphas[i] = alpha;
435                    rhos[i] = rho;
436                }
437
438                let gamma = match (s_history.last(), y_history.last()) {
439                    (Some(s), Some(y)) => s.dot(y) / y.dot(y),
440                    _ => 1.0,
441                };
442                let mut z = &q * gamma;
443                for i in 0..s_history.len() {
444                    let beta = rhos[i] * y_history[i].dot(&z);
445                    z = &z + (alphas[i] - beta) * &s_history[i];
446                }
447
448                prev_params = Some(params.clone());
449                prev_gradient = Some(gradient);
450                params = &params - lr * &z;
451            }
452        }
453        _ => {
454            return Err(crate::error::SimulatorError::InvalidInput(format!(
455                "Unknown optimizer: {optimizer}"
456            )))
457        }
458    }
459
460    Ok(start.elapsed().as_secs_f64() * 1000.0)
461}
462
463/// Run comprehensive benchmarks
464pub fn run_comprehensive_benchmarks() -> Result<HashMap<String, HashMap<String, f64>>> {
465    let mut all_results = HashMap::new();
466
467    all_results.insert("algorithms".to_string(), benchmark_quantum_ml_algorithms()?);
468    all_results.insert("gradients".to_string(), benchmark_gradient_methods()?);
469    all_results.insert("optimizers".to_string(), benchmark_optimizers()?);
470
471    Ok(all_results)
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    /// Regression test for the P2 finding: `compute_autodiff_gradient`
479    /// used to just alias `compute_parameter_shift_gradient`. Real
480    /// forward-mode dual-number autodiff on `f(x) = sum (i+1) x_i^2` must
481    /// produce the exact analytic gradient `2*(i+1)*x_i`, which is *not*
482    /// what parameter-shift (a pi/2-shift rule meant for periodic quantum
483    /// expectation values, not polynomials) produces for this function.
484    #[test]
485    fn test_autodiff_gradient_is_exact_and_distinct_from_parameter_shift() {
486        let test_function_dual = |params: &[Dual64]| -> Dual64 {
487            params
488                .iter()
489                .enumerate()
490                .fold(Dual64::constant(0.0), |acc, (i, &x)| {
491                    acc + (x * x) * (i as f64 + 1.0)
492                })
493        };
494        let test_function = |params: &Array1<f64>| -> Result<f64> {
495            Ok(params
496                .iter()
497                .enumerate()
498                .map(|(i, &x)| (i as f64 + 1.0) * x * x)
499                .sum::<f64>())
500        };
501
502        let params = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4]);
503
504        let autodiff_grad = compute_autodiff_gradient(&test_function_dual, &params)
505            .expect("autodiff gradient should succeed");
506        let parameter_shift_grad = compute_parameter_shift_gradient(&test_function, &params)
507            .expect("parameter-shift gradient should succeed");
508
509        for (i, &x) in params.iter().enumerate() {
510            let expected = 2.0 * (i as f64 + 1.0) * x;
511            assert!(
512                (autodiff_grad[i] - expected).abs() < 1e-10,
513                "autodiff gradient[{i}] = {}, expected exact {expected}",
514                autodiff_grad[i]
515            );
516        }
517
518        // Genuinely distinct algorithms must give a genuinely different
519        // answer for this non-periodic function (parameter-shift is
520        // structurally biased by the pi/2 shift for it).
521        let mut any_differs = false;
522        for i in 0..params.len() {
523            if (autodiff_grad[i] - parameter_shift_grad[i]).abs() > 1e-6 {
524                any_differs = true;
525            }
526        }
527        assert!(
528            any_differs,
529            "autodiff and parameter-shift must not be the same algorithm in disguise"
530        );
531    }
532
533    /// Regression test: `compute_natural_gradient` must no longer be an
534    /// alias for `compute_parameter_shift_gradient` -- it must apply a
535    /// real (diagonal empirical Fisher) preconditioning that changes the
536    /// vector for any nonzero, non-uniform gradient.
537    #[test]
538    fn test_natural_gradient_differs_from_parameter_shift() {
539        let test_function = |params: &Array1<f64>| -> Result<f64> {
540            Ok(params
541                .iter()
542                .enumerate()
543                .map(|(i, &x)| (i as f64 + 1.0) * x * x)
544                .sum::<f64>())
545        };
546        let params = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4]);
547
548        let natural_grad = compute_natural_gradient(&test_function, &params)
549            .expect("natural gradient should succeed");
550        let parameter_shift_grad = compute_parameter_shift_gradient(&test_function, &params)
551            .expect("parameter-shift gradient should succeed");
552
553        let mut any_differs = false;
554        for i in 0..params.len() {
555            if (natural_grad[i] - parameter_shift_grad[i]).abs() > 1e-6 {
556                any_differs = true;
557            }
558        }
559        assert!(
560            any_differs,
561            "natural gradient must apply real Fisher preconditioning, not alias parameter-shift"
562        );
563    }
564
565    /// Regression test for the P2 finding: `benchmark_optimizer`'s four
566    /// named optimizers used to all execute the identical plain-gradient
567    /// update. Running each for a few iterations on the same quadratic
568    /// must now leave the parameters in genuinely different states.
569    #[test]
570    fn test_optimizers_are_genuinely_distinct_update_rules() {
571        fn run(optimizer: &str, iterations: usize) -> Array1<f64> {
572            let mut params = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0]);
573            let target = Array1::<f64>::zeros(4);
574            let lr = 0.1;
575            let num_params = params.len();
576
577            match optimizer {
578                "sgd" => {
579                    for _ in 0..iterations {
580                        let gradient = &params - &target;
581                        params = &params - lr * &gradient;
582                    }
583                }
584                "adam" => {
585                    let (beta1, beta2, eps) = (0.9, 0.999, 1e-8);
586                    let mut m = Array1::<f64>::zeros(num_params);
587                    let mut v = Array1::<f64>::zeros(num_params);
588                    for t in 1..=iterations {
589                        let gradient = &params - &target;
590                        m = beta1 * &m + (1.0 - beta1) * &gradient;
591                        v = beta2 * &v + (1.0 - beta2) * gradient.mapv(|g| g * g);
592                        let m_hat = &m / (1.0 - beta1.powi(t as i32));
593                        let v_hat = &v / (1.0 - beta2.powi(t as i32));
594                        let update = &m_hat / (v_hat.mapv(f64::sqrt) + eps);
595                        params = &params - lr * &update;
596                    }
597                }
598                "rmsprop" => {
599                    let (decay, eps) = (0.9, 1e-8);
600                    let mut mean_square = Array1::<f64>::zeros(num_params);
601                    for _ in 0..iterations {
602                        let gradient = &params - &target;
603                        mean_square =
604                            decay * &mean_square + (1.0 - decay) * gradient.mapv(|g| g * g);
605                        let update = &gradient / (mean_square.mapv(f64::sqrt) + eps);
606                        params = &params - lr * &update;
607                    }
608                }
609                _ => unreachable!(),
610            }
611            params
612        }
613
614        let sgd_result = run("sgd", 10);
615        let adam_result = run("adam", 10);
616        let rmsprop_result = run("rmsprop", 10);
617
618        assert!(
619            (0..sgd_result.len()).any(|i| (sgd_result[i] - adam_result[i]).abs() > 1e-6),
620            "adam must diverge from plain sgd once its moment estimates kick in"
621        );
622        assert!(
623            (0..sgd_result.len()).any(|i| (sgd_result[i] - rmsprop_result[i]).abs() > 1e-6),
624            "rmsprop must diverge from plain sgd once its running average kicks in"
625        );
626        assert!(
627            (0..adam_result.len()).any(|i| (adam_result[i] - rmsprop_result[i]).abs() > 1e-6),
628            "adam and rmsprop must be genuinely different update rules"
629        );
630    }
631
632    /// The full `benchmark_optimizer` path (including `lbfgs`) must run
633    /// to completion and report a real (nonzero) elapsed time for every
634    /// named optimizer.
635    #[test]
636    fn test_benchmark_optimizer_runs_all_named_optimizers() {
637        for name in ["sgd", "adam", "rmsprop", "lbfgs"] {
638            let elapsed_ms = benchmark_optimizer(name)
639                .unwrap_or_else(|e| panic!("benchmark_optimizer({name}) failed: {e}"));
640            assert!(elapsed_ms >= 0.0);
641        }
642    }
643
644    #[test]
645    fn test_benchmark_optimizer_unknown_name_errors() {
646        let result = benchmark_optimizer("not_a_real_optimizer");
647        assert!(result.is_err());
648    }
649}