177 lines
6.2 KiB
Go
177 lines
6.2 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package tensor
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
)
|
|
|
|
// The leak harness. A kernel that returns must leave nothing behind:
|
|
// every parallel worker joined, every buffer handed back or
|
|
// unreachable, no reference parked in a package global. Go garbage
|
|
// collects, so a leak here means unbounded growth, not a lost block,
|
|
// and the check is the same shape either way: run the heavy paths,
|
|
// force collection, and require the goroutine count and the live heap
|
|
// to come back to where they started. The buffers sync.Pool hoards are
|
|
// released by the collections the check forces first.
|
|
|
|
// leakOps is the representative load: parallel kernels across every
|
|
// domain, the ones that spawn workers and the ones that allocate
|
|
// scratch proportional to the input, plus the untrusted-input readers.
|
|
// The data is sized so that a single retained buffer per round shows up
|
|
// far above the tolerance below: the vectors are 2 MiB and the matrix
|
|
// 32 KiB, while the tolerance is a fraction of a megabyte.
|
|
func leakOps(t *testing.T) []func() {
|
|
t.Helper()
|
|
const bigN = 1 << 18 // 2 MiB of float64
|
|
big := randA(t, 31, bigN)
|
|
med := randA(t, 32, 1<<16)
|
|
sq := mustA(t, med.RawFloats()[:64*64], 64, 64)
|
|
// Symmetric, diagonally dominant: both factorisations stay stable.
|
|
mm := sq.RawFloats()
|
|
for i := range 64 {
|
|
for j := range 64 {
|
|
mm[i*64+j] = (mm[i*64+j] + mm[j*64+i]) / 2
|
|
}
|
|
mm[i*64+i] += 20
|
|
}
|
|
sig := randA(t, 33, 1<<14)
|
|
// Files the readers must refuse: a header that lies about its sizes
|
|
// and a table header with no data behind it. Reading them allocates
|
|
// nothing beyond the few dozen bytes they contain, and nothing may
|
|
// stay behind either.
|
|
dir := t.TempDir()
|
|
hostileNC := filepath.Join(dir, "hostile.nc")
|
|
if err := os.WriteFile(hostileNC, hostileOracleHeader(), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hostileFITS := filepath.Join(dir, "hostile.fits")
|
|
if err := os.WriteFile(hostileFITS, hostileOracleTable(), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return []func(){
|
|
func() { _, _ = Add(big, big) },
|
|
func() { _, _ = Mul(big, big) },
|
|
func() { _, _ = MatMul2D(sq, sq) },
|
|
func() { _, _ = Einsum("ij,jk->ik", sq, sq) },
|
|
func() { _, _ = Sort(big) },
|
|
func() { _, _ = ArgSort(big) },
|
|
func() { _, _ = FFT(sig) },
|
|
func() { _, _, _, _ = SVD(sq) },
|
|
func() { _, _, _ = Eigen(sq) },
|
|
func() { _, _ = Inv(sq) },
|
|
func() { _, _ = Cholesky(sq) },
|
|
func() { _, _, _ = QR(sq) },
|
|
func() { _, _, _ = WelchPSD(sig, 1000, 256, 128, "hann") },
|
|
func() { _, _ = SavitzkyGolay(sig, 21, 3) },
|
|
func() { _, _ = DWT(sig, 4) },
|
|
func() { _, _ = CWT(sig, Morlet, []float64{1, 2, 4, 8, 16}, 1) },
|
|
func() { _, _ = SobolPoints(1<<12, 8, 0) },
|
|
func() { _, _ = HaltonPoints(1<<12, 8, 0) },
|
|
func() { _, _ = Gradient1D(sig, 1) },
|
|
func() { _, _ = Laplacian(sq, 1, 1) },
|
|
func() {
|
|
_, _ = IntegrateODE(func(t float64, y *Array) (*Array, error) {
|
|
return MulF(y, -1), nil
|
|
}, 0, 5, mustA(t, []float64{1}, 1), ODEOptions{MaxSteps: 100000})
|
|
},
|
|
func() { _, _, _, _ = LoadNetCDF(hostileNC) },
|
|
func() { _, _ = LoadFITSTable(hostileFITS) },
|
|
func() { _, _ = LoadHDF5(filepath.Join("io", "testdata", "h5", "fixture.h5")) },
|
|
func() {
|
|
_, _ = LinearRegression(mustA(t, randA(t, 34, 4096).RawFloats(), 4096, 1),
|
|
randA(t, 35, 4096))
|
|
},
|
|
}
|
|
}
|
|
|
|
// liveHeap returns the live heap after forcing every reclaimable byte
|
|
// to be collected twice: sync.Pool keeps one victim generation, so two
|
|
// rounds are what it takes for pooled buffers to be unreachable.
|
|
func liveHeap() (heap uint64, goroutines int) {
|
|
runtime.GC()
|
|
runtime.GC()
|
|
var m runtime.MemStats
|
|
runtime.ReadMemStats(&m)
|
|
return m.HeapAlloc, runtime.NumGoroutine()
|
|
}
|
|
|
|
// leakBlock runs one measurement block over the given operations and
|
|
// returns the live heap and goroutine count after it, so the leak
|
|
// detector itself can be measured by the same instrument it uses.
|
|
func leakBlock(ops []func(), rounds int) (heap uint64, goroutines int) {
|
|
for range rounds {
|
|
for _, op := range ops {
|
|
op()
|
|
}
|
|
}
|
|
return liveHeap()
|
|
}
|
|
|
|
// TestLeakHarnessDetectsLeaks is the harness's self-check: the same
|
|
// block measurement run over an operation that deliberately retains
|
|
// half a megabyte per round must fail, so a regression that empties
|
|
// the comparison or flips its direction cannot pass silently.
|
|
func TestLeakHarnessDetectsLeaks(t *testing.T) {
|
|
var retained [][]float64
|
|
leaky := []func(){
|
|
func() { retained = append(retained, make([]float64, 64<<10)) },
|
|
}
|
|
leakBlock(leaky, 3) // settle, as the real harness does
|
|
first, _ := leakBlock(leaky, 10)
|
|
second, _ := leakBlock(leaky, 10)
|
|
// The closure must stay reachable through the second block's
|
|
// collections: without a later use, the caller's slot is dead
|
|
// during the call and the collector would free exactly the data
|
|
// the self-check means to catch.
|
|
runtime.KeepAlive(leaky)
|
|
if second <= first+256<<10 {
|
|
t.Fatalf("the leak harness measured %d then %d bytes: a 5 MiB retention over 10 rounds went undetected", first, second)
|
|
}
|
|
}
|
|
|
|
func TestNoResourceLeaks(t *testing.T) {
|
|
ops := leakOps(t)
|
|
if len(ops) == 0 {
|
|
t.Fatal("the leak harness has no operations: a refactor emptied leakOps and the measurement below would pass vacuously")
|
|
}
|
|
run := func(rounds int) {
|
|
for range rounds {
|
|
for _, op := range ops {
|
|
op()
|
|
}
|
|
}
|
|
}
|
|
// Caches, pooled buffers and lazy tables settle in the first block.
|
|
// The measurement then compares successive blocks rather than the
|
|
// whole run against a baseline: a leak retains data per round and
|
|
// grows steadily block over block, while the runtime's own
|
|
// structures settle. Comparing endpoint to start once is what let
|
|
// a leak of a few hundred kilobytes per block hide under the
|
|
// settling curve.
|
|
const (
|
|
block = 10
|
|
tolerance = 256 << 10
|
|
)
|
|
run(3)
|
|
run(block)
|
|
prev, g0 := liveHeap()
|
|
for range 2 {
|
|
run(block)
|
|
heap, g := liveHeap()
|
|
if heap > prev+tolerance {
|
|
t.Errorf("live heap grew from %d to %d bytes over %d rounds of the facade load (tolerance %d): something retains data per round",
|
|
prev, heap, block, tolerance)
|
|
}
|
|
if g > g0 {
|
|
t.Errorf("goroutines grew from %d to %d over %d rounds of the facade load: a worker did not return",
|
|
g0, g, block)
|
|
}
|
|
prev = heap
|
|
}
|
|
}
|