feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
+176
@@ -0,0 +1,176 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user