Scalar types (Rust)¶
Every numerical kernel in the Rust crate is generic over the trait scatrs::Real. One kernel
therefore gives:
values, with
f64;extended-precision values, with
DoubleDouble;values with derivatives, with
Dual<f64, N>;extended-precision derivatives, with
Dual<DoubleDouble, N>;second derivatives, with nested duals
Dual<Dual<T, M>, N>.
The example scatrs/examples/scalar_types.rs (run it with
cargo run --example scalar_types) evaluates one generic kernel, \(\int_{-1}^{1} e^{ax}\,dx\)
by Gauss–Legendre quadrature, with each type:
//! One generic kernel evaluated with each scalar type: the example of the documentation page
//! "Scalar types (Rust)". Run it with `cargo run --example scalar_types`.
use scatrs::Real;
use scatrs::scalar::{DoubleDouble, Dual};
use scatrs::special::{gauss_legendre, wigner_d_vec};
/// ∫₋₁¹ exp(a·x) dx by an n-point Gauss–Legendre rule, for any scalar type.
fn integral_of_exp<T: Real>(a: T, n: usize) -> scatrs::Result<T> {
let rule = gauss_legendre::<T>(n)?;
let mut sum = T::zero();
for (&x, &w) in rule.nodes.iter().zip(&rule.weights) {
sum += w * (a * x).exp();
}
Ok(sum)
}
fn main() -> scatrs::Result<()> {
// Values, in f64 and in double-double. The exact integral is 2 sinh(a)/a.
let value = integral_of_exp(0.5_f64, 20)?;
let value_dd = integral_of_exp(DoubleDouble::from_f64(0.5), 20)?;
println!("f64: {value}, double-double: {value_dd:?}");
// A first derivative: seed a as the only variable (value 0.5, derivative 1).
let [a] = Dual::<f64, 1>::variables([0.5]);
let first = integral_of_exp(a, 20)?;
println!("value {}, d/da {}", first.re(), first.eps()[0]);
// A second derivative: nest two duals and seed both levels in the same direction.
let a2 = Dual::<Dual<f64, 1>, 1>::new(
Dual::new(0.5, [1.0]), // value 0.5, inner derivative 1
[Dual::new(1.0, [0.0])], // outer derivative 1, whose own derivative is 0
);
let second = integral_of_exp(a2, 20)?;
println!(
"value {}, d/da {} (= {}), d²/da² {}",
second.re().re(),
second.re().eps()[0],
second.eps()[0].re(),
second.eps()[0].eps()[0],
);
// Wigner d-functions with their derivative in x = cos θ.
let [x] = Dual::<f64, 1>::variables([0.3]);
let d = wigner_d_vec(2, -2, 100, x)?;
println!(
"d^50_(2,-2)(0.3) = {}, derivative {}",
d[50].re(),
d[50].eps()[0]
);
Ok(())
}
Dual::variables seeds independent variables: each gets derivative 1 in its own direction. For
a second derivative, nest two duals and seed both levels in the same direction. The value then
sits at .re().re(), the first derivative at both .re().eps()[0] and .eps()[0].re(), and
the second derivative at .eps()[0].eps()[0].
This page summarises the contracts that the rustdoc states for each type:
Real, DoubleDouble and
Dual (Rust API).
Throughout, \(u = 2^{-53}\) (the unit roundoff of f64) and \(u^2 = 2^{-106}\).
The Real trait¶
Real requires num_traits::Float and FloatConst, Debug + Send + Sync + 'static, the
compound assignment operators, and arithmetic with an f64 on the right (x * 2.0, not
2.0 * x). Because it requires Float, num_complex::Complex<T> works for every T: Real.
It adds three methods:
Method |
Meaning |
|---|---|
|
converts an |
|
the value of the unevaluated sum |
|
the nearest |
Notes for generic code¶
DoubleDouble and Dual follow f64 closely, but generic code must not assume more than
Float promises:
mul_addis fused forf64but not forDoubleDouble. The double-double version rounds the product before adding. Error-free transformations must not rely on it: Kahan’s idiome = b.mul_add(c, -(b * c))gives exactly 0 forDoubleDouble, not the rounding error ofb * c.epsilon()is a unit, not an error bound. It is \(2^{-52}\) forf64and \(2^{-104}\) forDoubleDouble. OneDoubleDoubledivision can err by up to about 2.5·epsilon().NumCast::fromkeeps only the leading word. Converting from anotherRealthis way goes throughto_f64. Use the value itself, orfrom_f64_pair, to keep full precision.Dualcompares values only, but tests zero strictly.==and<compare values only, whileis_zero,is_one,is_nanandis_finitealso inspect the derivatives. Branch on comparisons of values, as inx == T::zero(), where the derivatives should not matter.DualtakessinandcosfromT::sin_cos. They equalT::sinandT::coswhereversin_cosdoes, which is always the case forDoubleDouble. Forf64on Apple targets they can differ by an ulp; see below.
Writing a generic kernel¶
A checklist, following from the notes above and the rules below:
Write constants as
T::from_f64(0.5), or withFloatConst(T::PI()), and combineTwithf64with thef64on the right. A constant that needs more than a double takesT::from_f64_pair(hi, lo).Branch on comparisons of values (
x < T::zero()), not onis_zerooris_one, unless the derivatives should decide.Do not round-trip through
f64(approx_f64,to_f64,NumCast) inside the computation. That drops the double-double precision and the derivatives. Useapprox_f64only for decisions, such as choosing a branch or a number of terms.Do not use
mul_addas an error-free fused multiply-add.Write
absorhypot, not the square root of a square (see strong zeros).Check inputs and return
Error::InvalidInputrather than relying on NaN. Angles, for example, must stay within \(\lvert x\rvert \le 2^{50}\) for the double-double trigonometric functions.Test with
f64,DoubleDoubleandDual. The scatrs tests compareDualderivatives with double-double finite differences, andDoubleDoublevalues with the Arb reference.
DoubleDouble¶
A DoubleDouble is the unevaluated sum hi + lo of two f64s, normalised so that
hi == fl(hi + lo). That gives about 106 significant bits, roughly 32 decimal digits.
Algorithms. The basic operations are those of Joldes, Muller & Popescu, “Tight and rigorous error bounds for basic building blocks of double-word arithmetic”, ACM TOMS 44(2), 2017. They rely on a correctly rounded fused multiply-add, which
f64::mul_addprovides on every platform, in software where the hardware lacks it.Range. Full relative precision needs magnitudes of at least \(2^{-969}\) (\(\approx 2\cdot10^{-292}\)), which is
min_positive_value(). Below that the low word is subnormal and precision degrades gradually. Within about an ulp off64::MAXa result can overflow early.Special values. Non-finite results are
(hi, 0), so infinities do not turn into NaN. A zero result is(±0, +0), with the IEEE 754 sign of thef64operation on the leading words. Special values and signed zeros otherwise followf64.Constants. The
FloatConstvalues, and the internal tables of the elementary functions, are the certified splits stored in the reference fileconstants.jsonl.
Accuracy¶
These are the bounds stated in the rustdoc of each operation. They hold for results of magnitude at least \(2^{-969}\). Unless stated otherwise they are relative, and \(2^{-100}\) is \(64u^2\).
Operations |
Bound |
Basis |
|---|---|---|
|
\(3u^2\) |
proved (Joldes, Muller & Popescu), to leading order |
|
\(2u^2\) |
proved |
|
\(4u^2\) |
proved |
|
\(10u^2\) |
proved |
|
\(3u^2\) |
proved |
|
within \(2u^2\,\lvert b\rvert\) absolute |
exact remainder, rounded once |
|
\(4u^2\lvert x a\rvert + 3u^2\lvert x a + b\rvert\) absolute |
from the bounds above |
|
\(7u^2\), \(8u^2\) |
from the bounds above |
|
\(2^{-100}\) for results in \([2^{-969}, \text{MAX}]\); exact for integers below \(2^{106}\) |
first-order error analysis |
|
\(2^{-100}\) |
tested against Arb |
|
\(2^{-99}\) |
tested against Arb |
|
\(2^{-99}\max(1, \lvert y \ln\lvert x\rvert\rvert)\) |
tested against Arb |
|
\(16(\lvert n\rvert + 1)\,u^2\) |
tested against Arb |
|
\(2^{-100}\) for \(\lvert x\rvert \le \pi/4\); \(2^{-100}\) absolute for \(\lvert x\rvert \le 2^{50}\) |
tested against Arb |
|
\(2^{-99}\) for \(\lvert x\rvert \le \pi/4\); \(2^{-99}(1 + \lvert\tan x\rvert)/\lvert\cos x\rvert\) absolute |
tested against Arb |
|
\(2^{-99}\) |
tested against Arb |
What the basis column means:
Proved. The bound is the leading term of a theorem of the 2017 paper; terms of order \(u^3\) are dropped. The tests also check it against Arb on 2000 random and adversarial inputs per operation.
Tested against Arb. The bound holds on every reference input, random and hand-picked at the edges of each algorithm’s branches. It is not proved for all inputs.
The measured errors are well inside the bounds. For the arithmetic, the worst measured errors are:
\(2.0u^2\) for
+, \(1.4u^2\) for-, \(3.5u^2\) for*and \(4.3u^2\) for/;\(1.0u^2\) for
+ f64and* f64, and \(2.0u^2\) for/ f64.
For the functions tested against Arb, the output of the accuracy tests
(cargo test -p scatrs --release -- --nocapture, in tests/dd_elementary.rs and
tests/dd_trig.rs) gave these worst errors when these pages were written:
at most 0.11 of the bounds in the table, the largest being 0.102 for the
powiedge cases (0.047 fortan, the largest of the trigonometric functions);0.43 and 0.54 of the tighter bound with an absolute floor for
sinandcosnext to multiples of \(\pi/2\) (described below), at the edge cases.
Further details from the rustdoc:
Domain of
exp.expis validated for \(x \in [-670, 709.78]\). It overflows and underflows asf64::expdoes.Domain of the trigonometric functions.
sin,cosandtanreturn NaN for \(\lvert x\rvert > 2^{50}\). This is a documented domain limit, likesqrt(-1), not a silent failure.Absolute floor near multiples of \(\pi/2\). Next to a nonzero multiple \(k\pi/2\) the argument reduction leaves an absolute error floor. It is at most \(\lvert k\rvert\cdot2^{-161}\) for
f64arguments and \(\lvert k\rvert\cdot2^{-158}\) for double-double ones. The tests check that the error is within \(2^{-100}\lvert f\rvert\) plus that floor, so the relative error is at most \(1.5\cdot2^{-100}\) wherever \(\lvert x - k\pi/2\rvert \ge \lvert k\rvert\cdot2^{-60}\) (f64arguments) or \(\lvert k\rvert\cdot2^{-57}\) (double-double arguments). Some doubles lie closer to such multiples than that. At three of them the measured relative errors are \(2^{-100}\) at \(x = 45.553093477052\) (the closest of all below \(2^{50}\)), \(2^{-81}\) at \(x = 14461176.67027838\) and \(2^{-61}\) at \(x = 410195257422896.8\).sin_cos. It is bitwise equal to(sin, cos).Ranges.
atan2clamps its result to \([-\pi, \pi]\), using the double-double \(\pi\).Seeds from the platform.
ln,cbrtandatan2start from the platform’sf64::ln,f64::cbrtandf64::atan2. The results meet the bounds above whatever the last bits of those starting values. Their own last bits, though, may differ between platforms.
Use as num_traits::Float¶
DoubleDouble implements Float, FloatConst, NumCast, ToPrimitive and Real, so
generic kernels and Complex<DoubleDouble> work. Arithmetic and the elementary functions are
the inherent methods above, with their accuracy. Some methods act on the whole double-double:
rounding:
floor,ceil,round,truncandfract;abs,signumandcopysign;max,min,clampandabs_sub.
Classification (classify, is_normal, …), the sign tests and integer_decode describe the
leading word. Where DoubleDouble differs from f64:
epsilon()is \(2^{-104}\), a unit rather than an error bound.min_positive_value()is \(2^{-969}\), the smallest magnitude with full precision.classifyandis_normalstill call values in \([2^{-1022}, 2^{-969})\) normal.mul_addis not fused (see the table).maxandminignore a NaN argument, even a signaling one. For two zeros they follow IEEE 754-2019 maximum and minimum: \(\max(-0, +0) = +0\) and \(\min(-0, +0) = -0\), in either order.clampreturns NaN instead of panicking whenmin > maxor a bound is NaN.Conversion is exact where it can be:
ToPrimitivetruncates towards zero exactly, and returnsNoneoutside the target range.NumCast::fromis exact forf32,f64and integers below \(2^{106}\) in magnitude.
Parsing differs in its handling of whitespace:
DoubleDouble::from_decimal_strignores surrounding whitespace.str::parseaccepts exactly the strings thatf64accepts, so it does not.
Speed¶
f64::mul_add is one instruction where FMA is enabled. On x86_64 builds without the fma
target feature, which is the default, it is a much slower software call; see
Installation. Indicative timings (one-off measurements): exp takes about 200 ns
and sin about 170 ns, against 2.3 ns for an f64 sin.
Dual<T, N>¶
A Dual<T, N> is a value with N first-order directional derivatives,
\(\text{re} + \sum_i \text{eps}_i\,\varepsilon_i\). Dual::variables([...]) seeds independent
variables, and Dual::constant(v) makes a value with zero derivatives. re() and eps()
read the parts.
The value of every operation is T’s operation on the values, so rounding, special values and
signed zeros follow T bit for bit. For example, (a / b).re() == a.re() / b.re(). The one
exception is sin and cos; see below. The derivatives come from the chain
rule, evaluated in T.
Comparisons and zero tests¶
==, < and the other comparisons look only at the value, so a Dual orders like its value.
is_zero and is_one are strict: they also require every derivative to be zero. Generic
code uses them as shortcuts, and a value-only test would silently drop derivatives there.
num-complex 0.4.6, for example, does two things:
Complex::sqrtandcbrtreturn early when a componentis_zero().powfandpowcreturnone()when the exponentis_zero().
Take a refractive index \(m = n + 0i\) with \(\partial/\partial(\operatorname{Im} m)\) seeded. A
value-only is_zero would make m.sqrt() lose that derivative. ForwardDiff.jl made the same
choice (v1.0, PR #481).
is_nan and is_finite also inspect the derivatives, so a NaN derivative is never silent.
The other tests describe the value alone:
is_infinite,is_normalandis_subnormal;classify, the sign tests andinteger_decode.
A finite value with an infinite derivative is therefore neither NaN, nor infinite, nor finite.
In Complex<Dual<T, N>> the strict tests change some values. Where a component is zero in
value but carries a derivative, num-complex skips its shortcut and takes the general polar
path. Its derivatives are correct, but its values are not always bitwise those of
Complex<T>:
sqrton the imaginary axis differs by up to 2 ulps, andcbrtthere always differs (within 2 ulps in the tests).sqrt(-4 + 0i)gives \(\operatorname{Re} = 2\cos(\pi/2) \approx 1.2\cdot10^{-16}\) instead of 0.powfwith an exponent that is zero in value returns \(1 - 0i\) when \(\arg z < 0\), and NaN for a NaN base, instead of \(1 + 0i\).powcwith such an exponent returns NaN at \(z = 0\) and \(z = \infty\) instead of 1.ToPrimitivereturnsNonewhen the imaginary part is zero in value but has a derivative.Since
is_finitesees derivatives,Complex::expwith \(\operatorname{Re} z = +\infty\) takes its corner case when \(\operatorname{Im} z\) has an infinite or NaN derivative. It then returns \((+\infty, \operatorname{Im} z)\).
Strong zeros¶
A derivative component that is exactly zero is a strong zero: it contributes zero even when
multiplied by an infinite or NaN factor. So a direction on which no operand depends keeps
derivative 0 whatever the values are. Dual::constant(2.0) * Dual::constant(f64::INFINITY)
has zero derivatives, not NaN. A derivative never depends on N or on the other directions.
In arithmetic with finite operands, a derivative is NaN only in two cases:
the value is NaN too (
0 / 0,x % 0);two terms of its formula overflow with opposite signs (\(\infty - \infty\)).
Otherwise IEEE 754 rules apply: x / 0 has derivative \(x'/0 = \pm\infty\), for example, and a
nonzero derivative times an infinite value is infinite. The strong-zero rule applies to zero
derivatives only: an infinite or NaN derivative times a zero value is NaN, as in T. The sign
of a zero derivative is unspecified, and carries no information.
Caution
Strong zeros cannot tell a direction the operands do not depend on from one in which they are
stationary. For p seeded at 0, p * p rightly has derivative 0. So sqrt(p * p), or
(p * p).powf(0.25), gets derivative 0 at the singular point of the root, where \(\lvert p\rvert\)
has no derivative. The plain chain rule would give \(0\cdot\infty = \text{NaN}\) there. Write
p.abs() rather than the root of a square if \(\pm p'\) is wanted.
Points where a function is not differentiable¶
At these points scatrs makes a fixed choice. For a nonzero derivative \(x'\):
Case |
Derivative |
|---|---|
|
0, also at the jumps |
|
\(-x'\) where the sign changes (for |
|
those of the operand whose value is returned; at a tie, the one whose sign the result has |
|
\(+\infty\), the one-sided limit |
|
\(-\infty\) |
|
0 (a subgradient, since |
|
NaN (the function jumps there) |
|
0 |
|
the limit from the right in \(x\), \(1/(x\ln b) \to \pm\infty\) |
|
\(-\operatorname{sign}(\ln x)\cdot\infty\) in \(b\); 0 for \(x = 1\) |
|
0 in \(x\) everywhere, also at \(x = 0\), \(\infty\) or NaN |
|
\(y\,x^{y-1}\) is \(+\infty\), 1 or 0 for \(0 < y < 1\), \(y = 1\) and \(y > 1\) |
|
0 where \(x^y = 0\) with \(x \ge 0\); NaN for \(x < 0\) |
at \(\pm\infty\) |
the limits: 0 for |
outside a function’s domain ( |
NaN, except that the piecewise-constant functions keep 0, and |
hypotversusatan2at the origin. Because of these two choices,Complex::lnat \(z = 0\) gets derivative 0 in its real part, \(\ln\lvert z\rvert\), silently. NaN in the imaginary part flags the point.Derivatives that overflow. The chain rule multiplies \(x'\) by \(f'(x)\) evaluated in
T. Where \(f'(x)\) overflows or underflows, the derivative is \(\pm\infty\) or 0 even if the true value is representable. For example,atanhas derivative 0 beyond \(\lvert x\rvert \approx 1.3\cdot10^{154}\), where \(1/(1 + x^2)\) underflows.Nested duals. The limits at the singular points of
sqrt,ln,log2andlog10carry inner derivatives. The other special cases return constants, with zero inner derivatives.
sin and cos¶
Dual::sin and Dual::cos take their values and derivatives from one T::sin_cos, so that
value-only and derivative calls of a kernel agree. For DoubleDouble, sin_cos is bitwise
(sin, cos).
For f64 on Apple targets, LLVM fuses a sin and a cos of the same argument into one call
to __sincos_stret. Its sine can be an ulp off a lone f64::sin, for about 1 in 13 000
arguments (0.768039326588954 and −0.3820992815488752, for example). The Dual value is then
the one that any f64 code computing both functions gets, such as Complex::exp. f64 code
computing only sin x gets the lone sine, which Dual::sin may miss by an ulp. On glibc the
fused and separate values agree.
Accuracy of derivatives¶
Arithmetic. Each derivative formula is evaluated in T, so it errs by a few units in the
last place of its largest term. Cancellation between the terms is not compensated. An
intermediate can also overflow although the derivative is finite. For a / b with
a = 1e300, b = 10 and b′ = 1e10, for example, \(q\,b'\) overflows and the derivative is
\(-\infty\) where the true value is \(-10^{308}\).
Elementary functions. The tests (tests/dual_functions.rs) measured each function over
its whole domain against high-precision derivatives:
Dual<f64, N>. Within \(5.1u\) of the exact derivative at the argument, with Apple’s libm. The worst case istan. The tests bound each derivative by the measured errors of the libm results it is computed from, times their amplification, plus \(1u\) per rounding.Dual<DoubleDouble, N>. Within \(2^{-96}\) relative. For powers whoseDoubleDoublevalues are only that accurate, the bound is \(2^{-96}\max(1, \lvert y\ln x\rvert)\). For \(\partial/\partial x\) the bound uses \(\lvert (y - 1)\ln x\rvert\) instead.Small arguments. Derivatives computed from arguments or intermediates below \(2^{-969}\) lose precision, as such
DoubleDoublevalues do.
Cost¶
Operations are inlined. The derivatives are fixed-length arrays, which LLVM unrolls; for
N = 4 and N = 8 it packs them into SIMD lanes. Multiplication, division and the chain rule
check the values for special cases first. These checks are branches, so unlike the same loop
over f64, a loop over many Duals is not vectorised across elements. Division needs two
divisions: a/b for the value and 1/b for the derivatives.
Indicative throughput of Dual<f64, N> relative to plain f64 (one-off measurements on an
Apple M4):
Function |
Cost |
|---|---|
|
about 1.15× |
|
1.6–1.8× (one fused libm call) |
|
1.2× (\(N = 1\)), 1.8× (\(N = 4\)) |
|
about 2× |
|
about 4.4× (plain |