Files
tensor/signal/fft_precision_test.go
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

234 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package signal
import (
"math"
"math/big"
"testing"
)
// The fused radix-4 stages replace the radix-2 walk's arithmetic, so
// the accuracy question is answered against references this package
// does not implement: the quadratic definition in float64 (the one the
// functional tests read) and the same definition carried out in 256-bit
// floating point. The two paths under comparison are called directly,
// the radix-2 walk and the package dispatcher, so the error figures
// attribute cleanly to the stage kernels.
// precisionFixture returns the fixed test signal of length n: small
// integer-like values whose transform is far above the rounding floor
// of either path but far below the tolerances below.
func precisionFixture(n int) []complex128 {
vals := make([]complex128, n)
for i := range vals {
vals[i] = complex(float64(i%17)-8, float64(i%5)-2)
}
return vals
}
// TestFFTPathAccuracyAgainstNaive pins the fused radix-4 stages to the
// quadratic float64 reference at lengths the functional tests never
// reach (every length they hold sits below the radix-4 floor). The
// tolerance leaves the quadratic reference's own accumulated rounding
// an order of magnitude of headroom.
func TestFFTPathAccuracyAgainstNaive(t *testing.T) {
for _, n := range []int{64, 128, 256, 1024} {
vals := precisionFixture(n)
want := naiveDFT(vals, -1)
got := append([]complex128(nil), vals...)
fftPow2(got, -1)
for i := range want {
if math.Abs(real(got[i])-real(want[i])) > 1e-6 {
t.Fatalf("FFT(%d)[%d]: real error %g against the quadratic reference", n, i, math.Abs(real(got[i])-real(want[i])))
}
if math.Abs(imag(got[i])-imag(want[i])) > 1e-6 {
t.Fatalf("FFT(%d)[%d]: imaginary error %g against the quadratic reference", n, i, math.Abs(imag(got[i])-imag(want[i])))
}
}
}
}
func newBig(prec uint) *big.Float { return new(big.Float).SetPrec(prec) }
// machinPi computes π at the given precision by Machin's formula
// π = 16·atan(1/5) − 4·atan(1/239), the arctangents by their Taylor
// series.
func machinPi(prec uint) *big.Float {
atan := func(x *big.Float) *big.Float {
x2 := newBig(prec).Mul(x, x)
term := newBig(prec).Set(x)
sum := newBig(prec).Set(x)
tiny := newBig(prec).SetMantExp(big.NewFloat(1), -int(prec)-12)
for k := 1; k < 200; k++ {
term.Mul(term, x2)
// The denominator moves from 2k−1 to 2k+1 across the step.
term.Mul(term, big.NewFloat(float64(2*k-1)))
term.Quo(term, big.NewFloat(float64(2*k+1)))
if k%2 == 1 {
sum.Sub(sum, term)
} else {
sum.Add(sum, term)
}
if abs := newBig(prec).Abs(term); abs.Cmp(tiny) < 0 {
break
}
}
return sum
}
fifth := newBig(prec).Quo(big.NewFloat(1), big.NewFloat(5))
t239th := newBig(prec).Quo(big.NewFloat(1), big.NewFloat(239))
pi := atan(fifth)
pi.Mul(pi, big.NewFloat(16))
t := atan(t239th)
t.Mul(t, big.NewFloat(4))
return pi.Sub(pi, t)
}
// sincosBig evaluates cosine and sine of a finite angle at 256-bit
// precision: the angle is range-reduced into [−π, π] and both series
// are summed to well below the precision floor. The pair comes back
// real part first.
func sincosBig(theta *big.Float, prec uint) [2]*big.Float {
pi := machinPi(prec)
twoPi := newBig(prec).Mul(pi, big.NewFloat(2))
piNeg := newBig(prec).Neg(pi)
for theta.Cmp(pi) > 0 {
theta.Sub(theta, twoPi)
}
for theta.Cmp(piNeg) < 0 {
theta.Add(theta, twoPi)
}
return [2]*big.Float{sincosSeries(theta, false, prec), sincosSeries(theta, true, prec)}
}
// sincosSeries sums the Taylor series of sine (odd powers) or cosine
// (even powers) of a reduced angle.
func sincosSeries(theta *big.Float, sine bool, prec uint) *big.Float {
sum := newBig(prec)
term := newBig(prec)
if sine {
term.Set(theta)
} else {
term.SetInt64(1)
}
theta2 := newBig(prec).Mul(theta, theta)
tiny := newBig(prec).SetMantExp(big.NewFloat(1), -int(prec)-12)
for k := range 120 {
if k%2 == 1 {
sum.Sub(sum, term)
} else {
sum.Add(sum, term)
}
d1, d2 := 2*k+1, 2*k+2
if sine {
d1, d2 = 2*k+2, 2*k+3
}
term.Mul(term, theta2)
term.Quo(term, big.NewFloat(float64(d1)*float64(d2)))
if abs := newBig(prec).Abs(term); abs.Cmp(tiny) < 0 {
break
}
}
return sum
}
// bigFloatDFT evaluates the DFT by its quadratic definition at 256-bit
// precision: the twiddle of every residue is an exact-angle series
// evaluation, the accumulation runs in big.Float complex arithmetic,
// and the result is rounded once into complex128.
func bigFloatDFT(vals []complex128, sign float64) []complex128 {
const prec = 256
n := len(vals)
pi := machinPi(prec)
twoPi := newBig(prec).Mul(pi, big.NewFloat(2))
tw := make([][2]*big.Float, n)
for r := range n {
angle := newBig(prec).SetInt64(int64(r))
angle.Mul(angle, twoPi)
angle.Quo(angle, big.NewFloat(float64(n)))
if sign < 0 {
angle.Neg(angle)
}
tw[r] = sincosBig(angle, prec)
}
out := make([][2]*big.Float, n)
for k := range out {
out[k] = [2]*big.Float{newBig(prec), newBig(prec)}
}
vr, vi := newBig(prec), newBig(prec)
pr, pii := newBig(prec), newBig(prec)
tr, ti := newBig(prec), newBig(prec)
for k := range n {
re, im := out[k][0], out[k][1]
for j := range n {
t := tw[j*k%n]
vr.SetFloat64(real(vals[j]))
vi.SetFloat64(imag(vals[j]))
tr.Set(t[0])
ti.Set(t[1])
pr.Mul(vr, tr)
pii.Mul(vi, ti)
pr.Sub(pr, pii)
pii.Mul(vr, ti)
ti.Mul(vi, tr)
pii.Add(pii, ti)
re.Add(re, pr)
im.Add(im, pii)
}
}
res := make([]complex128, n)
for k := range n {
f64re, _ := out[k][0].Float64()
f64im, _ := out[k][1].Float64()
res[k] = complex(f64re, f64im)
}
return res
}
// TestFFTPrecisionReport compares the radix-2 walk and the fused
// radix-4 dispatch against the 256-bit quadratic reference and reports
// both maximum errors. The assertion only demands that the fused path
// hold the radix-2 path's accuracy within a small factor; the logged
// figures are the evidence the report quotes.
func TestFFTPrecisionReport(t *testing.T) {
for _, n := range []int{64, 256} {
vals := precisionFixture(n)
ref := bigFloatDFT(vals, -1)
oldPath := append([]complex128(nil), vals...)
fftRadix2(oldPath, -1)
newPath := append([]complex128(nil), vals...)
fftPow2(newPath, -1)
errOld, errNew := 0.0, 0.0
for i := range ref {
eo := math.Max(math.Abs(real(oldPath[i])-real(ref[i])), math.Abs(imag(oldPath[i])-imag(ref[i])))
en := math.Max(math.Abs(real(newPath[i])-real(ref[i])), math.Abs(imag(newPath[i])-imag(ref[i])))
errOld = math.Max(errOld, eo)
errNew = math.Max(errNew, en)
}
t.Logf("n=%d: radix-2 max error %.3e, fused radix-4 max error %.3e, ratio %.3f", n, errOld, errNew, errNew/errOld)
if errNew > 4*errOld+1e-13 {
t.Fatalf("n=%d: fused radix-4 error %.3e is worse than 4x the radix-2 error %.3e", n, errNew, errOld)
}
}
}
// TestBigFloatReferenceAgreesWithQuadratic guards the precision
// harness itself: at lengths this small the 256-bit reference and the
// float64 quadratic definition must agree to the float64 path's own
// rounding, so a broken series evaluation cannot pass unnoticed into
// the error figures above.
func TestBigFloatReferenceAgreesWithQuadratic(t *testing.T) {
for _, n := range []int{4, 8} {
vals := precisionFixture(n)
ref := bigFloatDFT(vals, -1)
naive := naiveDFT(vals, -1)
for i := range ref {
if math.Abs(real(ref[i])-real(naive[i])) > 1e-12 || math.Abs(imag(ref[i])-imag(naive[i])) > 1e-12 {
t.Fatalf("n=%d [%d]: 256-bit reference %v, quadratic %v", n, i, ref[i], naive[i])
}
}
}
}