356 lines
9.8 KiB
Go
356 lines
9.8 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
|
|
// SPDX-License-Identifier: MIT
|
|||
|
|
|
|||
|
|
package signal
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"math"
|
|||
|
|
"slices"
|
|||
|
|
"testing"
|
|||
|
|
|
|||
|
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Benchmarks for the kernels whose scheduling and per-element work was
|
|||
|
|
// reworked: the spectral Poisson solves, the Bluestein route for a
|
|||
|
|
// non-power-of-two length, the DCT/DST table path, the CWT wavelet
|
|||
|
|
// build, the nonuniform transform and the rank filters. Every input is a
|
|||
|
|
// fixed literal pattern, so the numbers are comparable between runs and
|
|||
|
|
// machines.
|
|||
|
|
|
|||
|
|
// benchPerfFloats builds a deterministic float array of the requested
|
|||
|
|
// rank from a fixed literal pattern.
|
|||
|
|
func benchPerfFloats(b *testing.B, n int, shape ...int) *core.Array {
|
|||
|
|
b.Helper()
|
|||
|
|
v := make([]float64, n)
|
|||
|
|
for i := range v {
|
|||
|
|
v[i] = math.Sin(float64(i)*0.017) + 0.25*float64(i%29) - 3
|
|||
|
|
}
|
|||
|
|
a, err := core.FromFloats(v, shape...)
|
|||
|
|
if err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
return a
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// poissonSource builds the right-hand side of a Poisson solve on a
|
|||
|
|
// rows×cols grid: a smooth interior bump that vanishes on the boundary,
|
|||
|
|
// the shape the Dirichlet solve is built for.
|
|||
|
|
func poissonSource(b *testing.B, rows, cols int) *core.Array {
|
|||
|
|
b.Helper()
|
|||
|
|
v := make([]float64, rows*cols)
|
|||
|
|
for r := range rows {
|
|||
|
|
for c := range cols {
|
|||
|
|
v[r*cols+c] = math.Sin(math.Pi*float64(r)/float64(rows-1)) *
|
|||
|
|
math.Sin(2*math.Pi*float64(c)/float64(cols-1))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
a, err := core.FromFloats(v, rows, cols)
|
|||
|
|
if err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
return a
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkSolvePoissonDirichlet measures the 512×512 sine-basis solve:
|
|||
|
|
// the per-mode division by the stencil eigenvalues and the two
|
|||
|
|
// separable DST-I passes. The grid is large enough for the eigenvalue
|
|||
|
|
// loop to be a visible share of the total.
|
|||
|
|
func BenchmarkSolvePoissonDirichlet(b *testing.B) {
|
|||
|
|
f := poissonSource(b, 512, 512)
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := SolvePoissonDirichlet(f, 1, 1); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkSolvePoissonNeumann is the cosine-basis twin on a 256×256
|
|||
|
|
// grid.
|
|||
|
|
func BenchmarkSolvePoissonNeumann(b *testing.B) {
|
|||
|
|
f := poissonSource(b, 256, 256)
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := SolvePoissonNeumann(f, 1, 1); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkFFTBluestein1000 measures a 1-D transform at a length that
|
|||
|
|
// is not a power of two, the only route to Bluestein's chirp-z
|
|||
|
|
// transform. The chirp and the kernel spectrum depend only on the
|
|||
|
|
// length and the direction, which is what the plan cache holds.
|
|||
|
|
func BenchmarkFFTBluestein1000(b *testing.B) {
|
|||
|
|
x := benchPerfFloats(b, 1000, 1000)
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := FFT(x); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkFFTBluestein10000 is the same route at a length whose padded
|
|||
|
|
// size is large enough for the kernel transform to dominate.
|
|||
|
|
func BenchmarkFFTBluestein10000(b *testing.B) {
|
|||
|
|
x := benchPerfFloats(b, 10000, 10000)
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := FFT(x); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkDCT2_1024 measures the type-2 cosine transform of a
|
|||
|
|
// 1024-sample vector: the per-sample phase table, one padded inverse
|
|||
|
|
// FFT and the per-bin rotation table.
|
|||
|
|
func BenchmarkDCT2_1024(b *testing.B) {
|
|||
|
|
x := benchPerfFloats(b, 1024, 1024)
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := DCT(x, 2); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkDST1_1023 measures the type-1 sine transform, whose phase
|
|||
|
|
// table is a constant fill and whose bin rotations are the widest.
|
|||
|
|
func BenchmarkDST1_1023(b *testing.B) {
|
|||
|
|
x := benchPerfFloats(b, 1023, 1023)
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := DST(x, 1); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkCWTMorletScales16 measures sixteen Morlet scales over a
|
|||
|
|
// 512-sample signal: the per-scale wavelet build (a cosine, a sine and
|
|||
|
|
// an exponential per sample, with the time axis wrapped once per
|
|||
|
|
// sample) and the FFT pair around it.
|
|||
|
|
func BenchmarkCWTMorletScales16(b *testing.B) {
|
|||
|
|
x := benchPerfFloats(b, 512, 512)
|
|||
|
|
scales := make([]float64, 16)
|
|||
|
|
for i := range scales {
|
|||
|
|
scales[i] = 1 + float64(i)
|
|||
|
|
}
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := CWT(x, Morlet, scales, 0.25); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkNUFFTType1_4096 measures the type-1 nonuniform transform:
|
|||
|
|
// 4096 samples spread onto an eight-times oversampled grid of 2048
|
|||
|
|
// points, one inverse FFT and the per-bin deconvolution.
|
|||
|
|
func BenchmarkNUFFTType1_4096(b *testing.B) {
|
|||
|
|
count := 4096
|
|||
|
|
xs := make([]float64, count)
|
|||
|
|
cs := make([]complex128, count)
|
|||
|
|
for j := range count {
|
|||
|
|
xs[j] = float64(j)/float64(count) - 0.5 + 1e-4
|
|||
|
|
cs[j] = complex(math.Sin(float64(j)*0.05), math.Cos(float64(j)*0.11))
|
|||
|
|
}
|
|||
|
|
x, err := core.FromFloats(xs, count)
|
|||
|
|
if err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
c, err := core.FromComplexes(cs, count)
|
|||
|
|
if err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := NUFFTType1(x, c, 512); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkMedianFilterWide31 measures the running median over a
|
|||
|
|
// 32768-sample signal with a 31-sample window: every output is the
|
|||
|
|
// middle order statistic of its own window.
|
|||
|
|
func BenchmarkMedianFilterWide31(b *testing.B) {
|
|||
|
|
x := benchPerfFloats(b, 1<<15, 1<<15)
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := MedianFilter(x, 31); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkRankFilterWide31_7 is the general-rank twin: the same window
|
|||
|
|
// read at rank 7 rather than at the middle.
|
|||
|
|
func BenchmarkRankFilterWide31_7(b *testing.B) {
|
|||
|
|
x := benchPerfFloats(b, 1<<15, 1<<15)
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := RankFilter(x, 31, 7); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkMedianFilter2D measures the 3×3 running median over a
|
|||
|
|
// 256×256 image, whose windows truncate at every edge.
|
|||
|
|
func BenchmarkMedianFilter2D(b *testing.B) {
|
|||
|
|
img := benchPerfFloats(b, 256*256, 256, 256)
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := MedianFilter2D(img, 3); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// rankFilterPerWindow is the per-window sort RankFilter used before the
|
|||
|
|
// sliding mirror, kept here as the reference the current kernel is
|
|||
|
|
// checked against bit for bit. A window is collected and sorted whole
|
|||
|
|
// for every output point, and the rank reads the sorted slice.
|
|||
|
|
func rankFilterPerWindow(x *core.Array, window, k int) *core.Array {
|
|||
|
|
n := x.Len()
|
|||
|
|
src := widenFloats(x)
|
|||
|
|
out := core.New(core.Float, n)
|
|||
|
|
dst := out.RawFloats()
|
|||
|
|
win := make([]float64, window)
|
|||
|
|
half := window / 2
|
|||
|
|
for i := range n {
|
|||
|
|
lo := max(i-half, 0)
|
|||
|
|
hi := min(i+half+1, n)
|
|||
|
|
count := hi - lo
|
|||
|
|
nan := false
|
|||
|
|
for j := lo; j < hi; j++ {
|
|||
|
|
v := src[j]
|
|||
|
|
if math.IsNaN(v) {
|
|||
|
|
nan = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
win[j-lo] = v
|
|||
|
|
}
|
|||
|
|
if nan {
|
|||
|
|
dst[i] = math.NaN()
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
slices.Sort(win[:count])
|
|||
|
|
dst[i] = win[k*count/window]
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestRankFilterMirrorMatchesPerWindow pins the sliding mirror to the
|
|||
|
|
// per-window sort bit for bit. The windows where the sort's own tie
|
|||
|
|
// order decides the answer, a zero next to a −0 and any NaN, are the
|
|||
|
|
// reason the mirror is dropped there; the patterns below hold ties,
|
|||
|
|
// signed zeros, duplicates, NaNs, constant runs and ramps, over every
|
|||
|
|
// signal length and window the gate admits.
|
|||
|
|
func TestRankFilterMirrorMatchesPerWindow(t *testing.T) {
|
|||
|
|
seed := uint64(0x2545F4914F6CDD1D)
|
|||
|
|
next := func() uint64 {
|
|||
|
|
seed ^= seed << 13
|
|||
|
|
seed ^= seed >> 7
|
|||
|
|
seed ^= seed << 17
|
|||
|
|
return seed
|
|||
|
|
}
|
|||
|
|
fill := func(pattern string, n int) []float64 {
|
|||
|
|
vals := make([]float64, n)
|
|||
|
|
for i := range vals {
|
|||
|
|
u := next()
|
|||
|
|
switch pattern {
|
|||
|
|
case "random":
|
|||
|
|
vals[i] = float64(int64(u%2001)-1000) / 8
|
|||
|
|
case "duplicates":
|
|||
|
|
vals[i] = float64(u % 4)
|
|||
|
|
case "signed zeros":
|
|||
|
|
switch u % 5 {
|
|||
|
|
case 0:
|
|||
|
|
vals[i] = math.Copysign(0, -1)
|
|||
|
|
case 1:
|
|||
|
|
vals[i] = 0
|
|||
|
|
default:
|
|||
|
|
vals[i] = float64(u%3) - 1
|
|||
|
|
}
|
|||
|
|
case "NaN":
|
|||
|
|
vals[i] = float64(u%7) - 3
|
|||
|
|
if u%11 == 0 {
|
|||
|
|
vals[i] = math.NaN()
|
|||
|
|
}
|
|||
|
|
case "constant":
|
|||
|
|
vals[i] = 2.5
|
|||
|
|
default:
|
|||
|
|
vals[i] = float64(i)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return vals
|
|||
|
|
}
|
|||
|
|
values := func(t *testing.T, vals []float64) *core.Array {
|
|||
|
|
t.Helper()
|
|||
|
|
a, err := core.FromFloats(vals, len(vals))
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
return a
|
|||
|
|
}
|
|||
|
|
for _, pattern := range []string{"random", "duplicates", "signed zeros", "NaN", "constant", "ramp"} {
|
|||
|
|
for n := 1; n <= 48; n++ {
|
|||
|
|
x := values(t, fill(pattern, n))
|
|||
|
|
for window := 3; window <= n; window += 2 {
|
|||
|
|
for k := range window {
|
|||
|
|
got, err := RankFilter(x, window, k)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("%s n=%d window=%d k=%d: %v", pattern, n, window, k, err)
|
|||
|
|
}
|
|||
|
|
want := rankFilterPerWindow(x, window, k)
|
|||
|
|
g, w := got.RawFloats(), want.RawFloats()
|
|||
|
|
for i := range g {
|
|||
|
|
if math.Float64bits(g[i]) != math.Float64bits(w[i]) {
|
|||
|
|
t.Fatalf("%s n=%d window=%d k=%d sample %d: got %v want %v",
|
|||
|
|
pattern, n, window, k, i, g[i], w[i])
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Long runs, so the mirror has slid far before the check.
|
|||
|
|
for _, pattern := range []string{"random", "duplicates", "signed zeros", "NaN"} {
|
|||
|
|
x := values(t, fill(pattern, 5000))
|
|||
|
|
for _, window := range []int{3, 5, 31, 997} {
|
|||
|
|
for _, k := range []int{0, 1, window / 2, window - 2, window - 1} {
|
|||
|
|
got, err := RankFilter(x, window, k)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
want := rankFilterPerWindow(x, window, k)
|
|||
|
|
g, w := got.RawFloats(), want.RawFloats()
|
|||
|
|
for i := range g {
|
|||
|
|
if math.Float64bits(g[i]) != math.Float64bits(w[i]) {
|
|||
|
|
t.Fatalf("%s window=%d k=%d sample %d: got %v want %v",
|
|||
|
|
pattern, window, k, i, g[i], w[i])
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BenchmarkFFTRealInput4096 measures the 1-D forward transform of a
|
|||
|
|
// real (float64) array: the payload is materialised rather than shared,
|
|||
|
|
// so the entry point adds no defensive copy of its own.
|
|||
|
|
func BenchmarkFFTRealInput4096(b *testing.B) {
|
|||
|
|
x := benchPerfFloats(b, 4096, 4096)
|
|||
|
|
b.ReportAllocs()
|
|||
|
|
for b.Loop() {
|
|||
|
|
if _, err := FFT(x); err != nil {
|
|||
|
|
b.Fatal(err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|