Skip to main content

phyz_diff/
multidual.rs

1//! Vector-mode forward AD: one pass through the dynamics, `N` derivatives out.
2//!
3//! [`tang::Dual`] carries one derivative alongside the value, so extracting an
4//! `n`-column Jacobian means running the function `n` times. Every one of those
5//! runs recomputes the same primal: the same kinematic tree walk, the same
6//! `sin`/`cos` per joint, the same memory traffic — all to produce one column.
7//!
8//! [`MultiDual<N>`] carries `N` derivatives instead. One pass computes the
9//! primal once and propagates `N` tangent directions through it, so the
10//! primal's cost is amortised across the whole chunk rather than paid `N`
11//! times. That is the difference between the adjoint's `nq + nv` dual-ABA
12//! passes per timestep and `⌈(nq + nv) / N⌉` of them.
13//!
14//! # Lane-for-lane identical to `Dual`
15//!
16//! Every derivative rule here is the same expression as [`tang::Dual`]'s, in
17//! the same association order, applied per lane. Lane `k` of a `MultiDual`
18//! computation is therefore **bit-identical** to a scalar `Dual` computation
19//! seeded in direction `k` — not merely close. That is what makes this a safe
20//! substitution inside a rollout that promises bitwise reproducibility, and it
21//! is asserted directly by `tests::lanes_match_scalar_dual_bitwise` over the
22//! full arithmetic and transcendental surface. (Not an intra-doc link: a
23//! `#[cfg(test)]` item does not exist in a docs build, so linking to one is an
24//! error under `-D warnings`.)
25//!
26//! Widening is therefore never a numerical change, only a speed one. If a
27//! gradient moves when `N` changes, that is a bug in this file.
28//!
29//! # Choosing `N`
30//!
31//! `N` is a const parameter, so each width is a separate monomorphisation with
32//! its tangent array in registers. Wider is not automatically better: lanes
33//! beyond the column count are wasted arithmetic, so a 1-DOF model wants
34//! `N = 1` and pays for anything larger. [`for_lanes`] does that dispatch —
35//! smallest supported width that covers the request.
36//!
37//! ```
38//! use phyz_diff::multidual::MultiDual;
39//! use tang::Scalar;
40//!
41//! // f(x, y) = x² · y, seeded in both directions at once.
42//! let x = MultiDual::<2>::var(3.0, 0);
43//! let y = MultiDual::<2>::var(5.0, 1);
44//! let f = x * x * y;
45//!
46//! assert_eq!(f.real, 45.0);
47//! assert_eq!(f.dual[0], 30.0); // ∂f/∂x = 2xy
48//! assert_eq!(f.dual[1], 9.0);  // ∂f/∂y = x²
49//! ```
50
51use core::fmt;
52use core::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign};
53use tang::Scalar;
54
55/// A real value carrying `N` simultaneous derivative lanes.
56///
57/// See the [module docs](self) for why the lanes are bit-identical to `N`
58/// separate [`tang::Dual`] evaluations.
59#[derive(Clone, Copy, Debug)]
60pub struct MultiDual<const N: usize> {
61    /// The value.
62    pub real: f64,
63    /// The derivative in each seeded direction.
64    pub dual: [f64; N],
65}
66
67impl<const N: usize> MultiDual<N> {
68    /// A constant: value with every derivative zero.
69    #[inline]
70    pub fn constant(real: f64) -> Self {
71        Self {
72            real,
73            dual: [0.0; N],
74        }
75    }
76
77    /// A variable seeded in lane `k`: `∂self/∂(lane k) = 1`, all others zero.
78    ///
79    /// Panics if `k >= N`, which is a programming error rather than a runtime
80    /// condition — a lane index out of range means the chunking is wrong.
81    #[inline]
82    pub fn var(real: f64, k: usize) -> Self {
83        assert!(k < N, "lane {k} out of range for MultiDual<{N}>");
84        let mut dual = [0.0; N];
85        dual[k] = 1.0;
86        Self { real, dual }
87    }
88
89    /// Construct with explicit derivatives.
90    #[inline]
91    pub fn new(real: f64, dual: [f64; N]) -> Self {
92        Self { real, dual }
93    }
94
95    /// Apply `f` to every lane — the shape every unary rule takes.
96    #[inline]
97    fn map(&self, f: impl Fn(f64) -> f64) -> [f64; N] {
98        core::array::from_fn(|k| f(self.dual[k]))
99    }
100
101    /// Combine lanes pairwise with `other` — the shape every binary rule takes.
102    #[inline]
103    fn zip(&self, other: &Self, f: impl Fn(f64, f64) -> f64) -> [f64; N] {
104        core::array::from_fn(|k| f(self.dual[k], other.dual[k]))
105    }
106}
107
108impl<const N: usize> Default for MultiDual<N> {
109    fn default() -> Self {
110        Self::constant(0.0)
111    }
112}
113
114impl<const N: usize> fmt::Display for MultiDual<N> {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        write!(f, "{}", self.real)
117    }
118}
119
120// Comparison is on the value alone, matching `tang::Dual`: an ordering that
121// consulted the tangents would make `min`/`max`/`select` disagree with the
122// scalar path and silently change which branch a rollout takes.
123impl<const N: usize> PartialEq for MultiDual<N> {
124    fn eq(&self, other: &Self) -> bool {
125        self.real == other.real
126    }
127}
128
129impl<const N: usize> PartialOrd for MultiDual<N> {
130    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
131        self.real.partial_cmp(&other.real)
132    }
133}
134
135impl<const N: usize> Add for MultiDual<N> {
136    type Output = Self;
137    #[inline]
138    fn add(self, o: Self) -> Self {
139        Self {
140            real: self.real + o.real,
141            dual: self.zip(&o, |a, b| a + b),
142        }
143    }
144}
145
146impl<const N: usize> Sub for MultiDual<N> {
147    type Output = Self;
148    #[inline]
149    fn sub(self, o: Self) -> Self {
150        Self {
151            real: self.real - o.real,
152            dual: self.zip(&o, |a, b| a - b),
153        }
154    }
155}
156
157impl<const N: usize> Mul for MultiDual<N> {
158    type Output = Self;
159    #[inline]
160    // The `+` inside `Mul` is the product rule, not a transposed operator:
161    // d(xy) = x·dy + y·dx. Clippy's heuristic cannot tell the two apart.
162    #[allow(clippy::suspicious_arithmetic_impl)]
163    fn mul(self, o: Self) -> Self {
164        // Associated exactly as `Dual`'s is:
165        // `self.dual * o.real + self.real * o.dual`.
166        Self {
167            real: self.real * o.real,
168            dual: self.zip(&o, |a, b| a * o.real + self.real * b),
169        }
170    }
171}
172
173impl<const N: usize> Div for MultiDual<N> {
174    type Output = Self;
175    #[inline]
176    fn div(self, o: Self) -> Self {
177        let inv = 1.0 / o.real;
178        Self {
179            real: self.real * inv,
180            dual: self.zip(&o, |a, b| (a * o.real - self.real * b) * (inv * inv)),
181        }
182    }
183}
184
185impl<const N: usize> Neg for MultiDual<N> {
186    type Output = Self;
187    #[inline]
188    fn neg(self) -> Self {
189        Self {
190            real: -self.real,
191            dual: self.map(|a| -a),
192        }
193    }
194}
195
196impl<const N: usize> AddAssign for MultiDual<N> {
197    #[inline]
198    fn add_assign(&mut self, o: Self) {
199        *self = *self + o;
200    }
201}
202impl<const N: usize> SubAssign for MultiDual<N> {
203    #[inline]
204    fn sub_assign(&mut self, o: Self) {
205        *self = *self - o;
206    }
207}
208impl<const N: usize> MulAssign for MultiDual<N> {
209    #[inline]
210    fn mul_assign(&mut self, o: Self) {
211        *self = *self * o;
212    }
213}
214impl<const N: usize> DivAssign for MultiDual<N> {
215    #[inline]
216    fn div_assign(&mut self, o: Self) {
217        *self = *self / o;
218    }
219}
220
221impl<const N: usize> Scalar for MultiDual<N> {
222    const ZERO: Self = Self {
223        real: 0.0,
224        dual: [0.0; N],
225    };
226    const ONE: Self = Self {
227        real: 1.0,
228        dual: [0.0; N],
229    };
230    const TWO: Self = Self {
231        real: 2.0,
232        dual: [0.0; N],
233    };
234    const HALF: Self = Self {
235        real: 0.5,
236        dual: [0.0; N],
237    };
238    const PI: Self = Self {
239        real: core::f64::consts::PI,
240        dual: [0.0; N],
241    };
242    const TAU: Self = Self {
243        real: core::f64::consts::TAU,
244        dual: [0.0; N],
245    };
246    const FRAC_PI_2: Self = Self {
247        real: core::f64::consts::FRAC_PI_2,
248        dual: [0.0; N],
249    };
250    const EPSILON: Self = Self {
251        real: f64::EPSILON,
252        dual: [0.0; N],
253    };
254    const INFINITY: Self = Self {
255        real: f64::INFINITY,
256        dual: [0.0; N],
257    };
258    const NEG_INFINITY: Self = Self {
259        real: f64::NEG_INFINITY,
260        dual: [0.0; N],
261    };
262
263    #[inline]
264    fn sqrt(self) -> Self {
265        let r = self.real.sqrt();
266        Self {
267            real: r,
268            dual: self.map(|a| a / (2.0 * r)),
269        }
270    }
271
272    #[inline]
273    fn abs(self) -> Self {
274        let s = self.real.signum();
275        Self {
276            real: self.real.abs(),
277            dual: self.map(|a| a * s),
278        }
279    }
280
281    #[inline]
282    fn sin(self) -> Self {
283        let c = self.real.cos();
284        Self {
285            real: self.real.sin(),
286            dual: self.map(|a| a * c),
287        }
288    }
289
290    #[inline]
291    fn cos(self) -> Self {
292        let s = self.real.sin();
293        Self {
294            real: self.real.cos(),
295            dual: self.map(|a| -a * s),
296        }
297    }
298
299    #[inline]
300    fn tan(self) -> Self {
301        let c = self.real.cos();
302        Self {
303            real: self.real.tan(),
304            dual: self.map(|a| a / (c * c)),
305        }
306    }
307
308    #[inline]
309    fn asin(self) -> Self {
310        let d = (1.0 - self.real * self.real).sqrt();
311        Self {
312            real: self.real.asin(),
313            dual: self.map(|a| a / d),
314        }
315    }
316
317    #[inline]
318    fn acos(self) -> Self {
319        let d = (1.0 - self.real * self.real).sqrt();
320        Self {
321            real: self.real.acos(),
322            dual: self.map(|a| -a / d),
323        }
324    }
325
326    #[inline]
327    fn atan2(self, other: Self) -> Self {
328        let denom = self.real * self.real + other.real * other.real;
329        Self {
330            real: self.real.atan2(other.real),
331            dual: self.zip(&other, |a, b| (a * other.real - self.real * b) / denom),
332        }
333    }
334
335    #[inline]
336    fn sin_cos(self) -> (Self, Self) {
337        let (s, c) = self.real.sin_cos();
338        (
339            Self {
340                real: s,
341                dual: self.map(|a| a * c),
342            },
343            Self {
344                real: c,
345                dual: self.map(|a| -a * s),
346            },
347        )
348    }
349
350    #[inline]
351    fn min(self, other: Self) -> Self {
352        if self.real < other.real { self } else { other }
353    }
354
355    #[inline]
356    fn max(self, other: Self) -> Self {
357        if self.real > other.real { self } else { other }
358    }
359
360    #[inline]
361    fn clamp(self, lo: Self, hi: Self) -> Self {
362        self.max(lo).min(hi)
363    }
364
365    #[inline]
366    fn recip(self) -> Self {
367        let inv = self.real.recip();
368        Self {
369            real: inv,
370            dual: self.map(|a| -a * inv * inv),
371        }
372    }
373
374    #[inline]
375    fn powi(self, n: i32) -> Self {
376        let r = self.real.powi(n);
377        let d = n as f64 * self.real.powi(n - 1);
378        Self {
379            real: r,
380            dual: self.map(|a| a * d),
381        }
382    }
383
384    #[inline]
385    fn copysign(self, sign: Self) -> Self {
386        let flipped = self.real.signum() != sign.real.signum();
387        Self {
388            real: self.real.copysign(sign.real),
389            dual: self.map(|a| if flipped { -a } else { a }),
390        }
391    }
392
393    #[inline]
394    fn signum(self) -> Self {
395        Self::constant(self.real.signum())
396    }
397
398    #[inline]
399    fn floor(self) -> Self {
400        Self::constant(self.real.floor())
401    }
402
403    #[inline]
404    fn ceil(self) -> Self {
405        Self::constant(self.real.ceil())
406    }
407
408    #[inline]
409    fn round(self) -> Self {
410        Self::constant(self.real.round())
411    }
412
413    #[inline]
414    fn exp(self) -> Self {
415        let e = self.real.exp();
416        Self {
417            real: e,
418            dual: self.map(|a| a * e),
419        }
420    }
421
422    #[inline]
423    fn ln(self) -> Self {
424        Self {
425            real: self.real.ln(),
426            dual: self.map(|a| a / self.real),
427        }
428    }
429
430    #[inline]
431    fn powf(self, p: Self) -> Self {
432        let val = self.real.powf(p.real);
433        let ln_x = self.real.ln();
434        Self {
435            real: val,
436            dual: self.zip(&p, |a, b| val * (b * ln_x + p.real * a / self.real)),
437        }
438    }
439
440    #[inline]
441    fn tanh(self) -> Self {
442        let t = self.real.tanh();
443        Self {
444            real: t,
445            dual: self.map(|a| a * (1.0 - t * t)),
446        }
447    }
448
449    #[inline]
450    fn sinh(self) -> Self {
451        let c = self.real.cosh();
452        Self {
453            real: self.real.sinh(),
454            dual: self.map(|a| a * c),
455        }
456    }
457
458    #[inline]
459    fn cosh(self) -> Self {
460        let s = self.real.sinh();
461        Self {
462            real: self.real.cosh(),
463            dual: self.map(|a| a * s),
464        }
465    }
466
467    #[inline]
468    fn acosh(self) -> Self {
469        let d = (self.real * self.real - 1.0).sqrt();
470        Self {
471            real: self.real.acosh(),
472            dual: self.map(|a| a / d),
473        }
474    }
475
476    #[inline]
477    fn asinh(self) -> Self {
478        let d = (self.real * self.real + 1.0).sqrt();
479        Self {
480            real: self.real.asinh(),
481            dual: self.map(|a| a / d),
482        }
483    }
484
485    #[inline]
486    fn atanh(self) -> Self {
487        let d = 1.0 - self.real * self.real;
488        Self {
489            real: self.real.atanh(),
490            dual: self.map(|a| a / d),
491        }
492    }
493
494    #[inline]
495    fn from_f64(v: f64) -> Self {
496        Self::constant(v)
497    }
498
499    #[inline]
500    fn to_f64(self) -> f64 {
501        self.real
502    }
503
504    #[inline]
505    fn from_i32(v: i32) -> Self {
506        Self::constant(v as f64)
507    }
508
509    #[inline]
510    fn select(cond: Self, a: Self, b: Self) -> Self {
511        if cond.real > 0.0 { a } else { b }
512    }
513}
514
515/// The widths [`for_lanes`] can dispatch to, narrowest first.
516///
517/// Powers of two up to 16. Beyond that the tangent array stops living in
518/// registers and the amortisation reverses; a model wanting more columns runs
519/// several 16-wide chunks instead.
520pub const SUPPORTED_WIDTHS: [usize; 5] = [1, 2, 4, 8, 16];
521
522/// The width a chunk of `lanes` columns should use: the narrowest supported
523/// width that covers it, capped at 16.
524///
525/// Lanes beyond the column count are wasted arithmetic, so this never rounds
526/// up further than it must — a 1-DOF model gets `N = 1` and pays exactly what
527/// the scalar `Dual` path pays.
528pub fn width_for(lanes: usize) -> usize {
529    *SUPPORTED_WIDTHS
530        .iter()
531        .find(|&&w| w >= lanes)
532        .unwrap_or(SUPPORTED_WIDTHS.last().expect("non-empty"))
533}
534
535/// Call `f` monomorphised at the width [`width_for`] picks for `lanes`.
536///
537/// The const parameter has to be known at compile time, so the runtime column
538/// count is turned into one of a fixed set of instantiations here rather than
539/// at every call site.
540///
541/// ```
542/// use phyz_diff::multidual::{MultiDual, for_lanes};
543/// use tang::Scalar;
544///
545/// // Differentiate f(x) = Σ xᵢ² in however many directions were asked for.
546/// let xs = [1.0, 2.0, 3.0];
547/// let grad = for_lanes(xs.len(), Grad(&xs));
548/// assert_eq!(grad, vec![2.0, 4.0, 6.0]);
549///
550/// struct Grad<'a>(&'a [f64]);
551/// impl phyz_diff::multidual::LaneOp for Grad<'_> {
552///     type Out = Vec<f64>;
553///     fn call<const N: usize>(self) -> Vec<f64> {
554///         let seeded: Vec<MultiDual<N>> = self
555///             .0
556///             .iter()
557///             .enumerate()
558///             .map(|(i, &x)| MultiDual::var(x, i))
559///             .collect();
560///         let f = seeded
561///             .iter()
562///             .fold(MultiDual::<N>::constant(0.0), |acc, &x| acc + x * x);
563///         (0..self.0.len()).map(|i| f.dual[i]).collect()
564///     }
565/// }
566/// ```
567pub fn for_lanes<Op: LaneOp>(lanes: usize, op: Op) -> Op::Out {
568    match width_for(lanes) {
569        1 => op.call::<1>(),
570        2 => op.call::<2>(),
571        4 => op.call::<4>(),
572        8 => op.call::<8>(),
573        _ => op.call::<16>(),
574    }
575}
576
577/// A computation that is generic over the lane width, for [`for_lanes`].
578///
579/// This is a trait rather than a closure because a closure cannot be generic
580/// over a const parameter.
581pub trait LaneOp {
582    /// What the computation produces.
583    type Out;
584    /// Run it at width `N`.
585    fn call<const N: usize>(self) -> Self::Out;
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use tang::Dual;
592
593    /// Exercise a chain long enough to compound any rule that disagrees:
594    /// every arithmetic operator and every transcendental the dynamics can
595    /// reach.
596    fn workload<S: Scalar>(x: S, y: S) -> S {
597        let a = x * y + x / y - y;
598        let b = a.sin() * a.cos() + a.tan();
599        let c = (b * b + S::ONE).sqrt().ln();
600        let d = c.exp() * x.powi(3) + y.powf(S::from_f64(1.7));
601        let e = d.recip() + d.abs() - d.tanh() + d.sinh() * d.cosh();
602        let f = e.atan2(x) + x.max(y) - x.min(y) + e.signum() * e;
603        f + x.asin() + y.acos() + (x * S::TWO).asinh() + (y + S::TWO).acosh() + x.atanh()
604    }
605
606    /// The property this whole module rests on: lane `k` of a `MultiDual` run
607    /// equals a scalar `Dual` run seeded in direction `k`, **bit for bit**.
608    ///
609    /// Not "to 1e-15" — exactly. A rollout that promises bitwise
610    /// reproducibility cannot have its gradients shift because a chunk width
611    /// changed, so approximate agreement here would not be good enough.
612    #[test]
613    fn lanes_match_scalar_dual_bitwise() {
614        let (x0, y0) = (0.37, 0.61);
615
616        let dx = workload(Dual::var(x0), Dual::constant(y0));
617        let dy = workload(Dual::constant(x0), Dual::var(y0));
618
619        let m = workload(MultiDual::<2>::var(x0, 0), MultiDual::<2>::var(y0, 1));
620
621        assert_eq!(m.real.to_bits(), dx.real.to_bits(), "value");
622        assert_eq!(m.dual[0].to_bits(), dx.dual.to_bits(), "lane 0 vs ∂/∂x");
623        assert_eq!(m.dual[1].to_bits(), dy.dual.to_bits(), "lane 1 vs ∂/∂y");
624    }
625
626    /// Widening must not change a lane's value: the same seed at width 1, 4
627    /// and 16 has to produce identical bits, or `for_lanes` would make results
628    /// depend on the model's DOF count.
629    #[test]
630    fn width_does_not_change_the_answer() {
631        let (x0, y0) = (0.37, 0.61);
632        let w1 = workload(MultiDual::<1>::var(x0, 0), MultiDual::<1>::constant(y0));
633        let w4 = workload(MultiDual::<4>::var(x0, 0), MultiDual::<4>::constant(y0));
634        let w16 = workload(MultiDual::<16>::var(x0, 0), MultiDual::<16>::constant(y0));
635
636        assert_eq!(w1.dual[0].to_bits(), w4.dual[0].to_bits());
637        assert_eq!(w1.dual[0].to_bits(), w16.dual[0].to_bits());
638    }
639
640    /// Unseeded lanes must stay exactly zero — a lane that picks up numerical
641    /// dust would silently pollute the padding columns of the last chunk.
642    #[test]
643    fn unseeded_lanes_stay_zero() {
644        let m = workload(MultiDual::<8>::var(0.37, 0), MultiDual::<8>::constant(0.61));
645        for (k, d) in m.dual.iter().enumerate().skip(1) {
646            assert_eq!(*d, 0.0, "lane {k} picked up a nonzero derivative");
647        }
648    }
649
650    #[test]
651    fn width_dispatch_picks_the_narrowest_cover() {
652        assert_eq!(width_for(0), 1);
653        assert_eq!(width_for(1), 1);
654        assert_eq!(width_for(2), 2);
655        assert_eq!(width_for(3), 4);
656        assert_eq!(width_for(8), 8);
657        assert_eq!(width_for(9), 16);
658        // Beyond the widest supported width, chunking is the caller's job.
659        assert_eq!(width_for(17), 16);
660        assert_eq!(width_for(1000), 16);
661    }
662
663    #[test]
664    fn for_lanes_reaches_the_expected_width() {
665        struct Width;
666        impl LaneOp for Width {
667            type Out = usize;
668            fn call<const N: usize>(self) -> usize {
669                N
670            }
671        }
672        assert_eq!(for_lanes(1, Width), 1);
673        assert_eq!(for_lanes(5, Width), 8);
674        assert_eq!(for_lanes(40, Width), 16);
675    }
676
677    /// A derivative that is actually right, checked against a closed form
678    /// rather than only against the scalar path — so a rule that is wrong in
679    /// both places cannot hide.
680    #[test]
681    fn derivatives_are_correct_not_merely_consistent() {
682        // f(x, y) = x³·sin(y); ∂f/∂x = 3x²·sin(y), ∂f/∂y = x³·cos(y).
683        let (x, y) = (1.3_f64, 0.7_f64);
684        let f = MultiDual::<2>::var(x, 0).powi(3) * MultiDual::<2>::var(y, 1).sin();
685
686        assert!((f.real - x.powi(3) * y.sin()).abs() < 1e-14);
687        assert!((f.dual[0] - 3.0 * x * x * y.sin()).abs() < 1e-14);
688        assert!((f.dual[1] - x.powi(3) * y.cos()).abs() < 1e-14);
689    }
690}