Files
tensor/leak_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

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
}
}