Files
zeekayandHanzo Dev a449465dc6 fhe: batch work follows the cores the machine has
The pure-Go batch paths split every batch across exactly four goroutines, so a
machine with more cores than that ran the remainder idle and throughput stopped
improving once a batch outgrew four chunks. Measured on an 8-core node (6
allocatable), forward NTT:

     batch    before          after
         8    10,229/s        11,223/s
        64    10,878/s        15,252/s
       256    12,617/s        18,989/s

Worker count now follows GOMAXPROCS, capped by the batch (an empty chunk is a
scheduler slot spent on nothing) and floored at one (a constrained GOMAXPROCS
must not yield a zero-worker loop that skips the batch).

The path had no test of any kind: every other file in the package is
//go:build cgo, so a CGO_ENABLED=0 build compiled this file and ran nothing
against it -- while being the build a CPU-only node runs, and the only one that
cross-compiles to a cluster node. Adds a round-trip correctness test and
benchmarks at the same ring parameters as the cgo ones, including 4 and 8 to
straddle the sequential/parallel boundary.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-18 21:50:07 -07:00

141 lines
3.9 KiB
Go

//go:build !cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// The pure-Go accelerator had no test of any kind: every other file in this
// package is //go:build cgo, so a CGO_ENABLED=0 build compiled fhe.go and then
// ran nothing against it. That is the build a CPU-only node runs, and it is the
// only build that cross-compiles cleanly to a cluster node, so it is the one
// worth measuring.
//
// Parameters mirror the cgo benchmarks (N=16384, one NTT-friendly prime) so the
// two paths can be compared directly rather than approximately.
package fhe
import (
"testing"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/log"
)
const (
benchN = 16384
benchQ = uint64(0x7fffffffe0001)
)
func cpuRing(tb testing.TB) (*FHEAccelerator, *ring.Ring) {
tb.Helper()
accel, err := NewFHEAccelerator(log.Noop())
if err != nil {
tb.Fatalf("NewFHEAccelerator: %v", err)
}
r, err := ring.NewRing(benchN, []uint64{benchQ})
if err != nil {
tb.Fatalf("NewRing: %v", err)
}
return accel, r
}
func polys(r *ring.Ring, n int) []ring.Poly {
out := make([]ring.Poly, n)
for i := range out {
out[i] = r.NewPoly()
for j := 0; j < benchN; j++ {
out[i].Coeffs[0][j] = uint64(i*benchN+j) % benchQ
}
}
return out
}
// TestCPUBackendRoundTrip is the correctness floor the benchmarks stand on: a
// number that comes from an operation computing the wrong thing is not a
// measurement. Forward then inverse NTT must return the original coefficients.
func TestCPUBackendRoundTrip(t *testing.T) {
accel, r := cpuRing(t)
defer accel.Close()
// IsEnabled reports that the accelerator is usable, not that a GPU exists —
// the pure-Go build is enabled and CPU-backed. The backend string is what
// separates the two, so that is what this pins.
if got := accel.Backend(); got != "CPU (Pure Go lattice)" {
t.Fatalf("the !cgo build must run the pure-Go CPU backend, got %q", got)
}
in := polys(r, 16)
want := make([][]uint64, len(in))
for i := range in {
want[i] = append([]uint64(nil), in[i].Coeffs[0]...)
}
if err := accel.BatchNTTForward(r, in); err != nil {
t.Fatalf("BatchNTTForward: %v", err)
}
if err := accel.BatchNTTInverse(r, in); err != nil {
t.Fatalf("BatchNTTInverse: %v", err)
}
for i := range in {
for j := 0; j < benchN; j++ {
if in[i].Coeffs[0][j] != want[i][j] {
t.Fatalf("round-trip mismatch at poly %d coeff %d: got %d want %d",
i, j, in[i].Coeffs[0][j], want[i][j])
}
}
}
}
func benchBatch(b *testing.B, n int) {
accel, r := cpuRing(b)
defer accel.Close()
p := polys(r, n)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := accel.BatchNTTForward(r, p); err != nil {
b.Fatal(err)
}
}
b.StopTimer()
// polys/sec is the number that matters for sizing: op latency alone hides
// whether a bigger batch buys anything.
b.ReportMetric(float64(n)*float64(b.N)/b.Elapsed().Seconds(), "polys/sec")
}
func BenchmarkCPUNTTForward(b *testing.B) {
accel, r := cpuRing(b)
defer accel.Close()
p := polys(r, 1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := accel.BatchNTTForward(r, p); err != nil {
b.Fatal(err)
}
}
}
// 4 and 8 straddle the sequential/parallel split in BatchNTTForward: below 8 it
// runs inline, at 8 and above it fans out to a FIXED four workers. The pair says
// whether that boundary is where the speedup actually starts.
func BenchmarkCPUBatchNTT4(b *testing.B) { benchBatch(b, 4) }
func BenchmarkCPUBatchNTT8(b *testing.B) { benchBatch(b, 8) }
func BenchmarkCPUBatchNTT16(b *testing.B) { benchBatch(b, 16) }
func BenchmarkCPUBatchNTT64(b *testing.B) { benchBatch(b, 64) }
func BenchmarkCPUBatchNTT256(b *testing.B) { benchBatch(b, 256) }
func BenchmarkCPUPolyMul(b *testing.B) {
accel, r := cpuRing(b)
defer accel.Close()
x, y := polys(r, 1), polys(r, 1)
out := []ring.Poly{r.NewPoly()}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := accel.BatchPolyMul(r, x, y, out); err != nil {
b.Fatal(err)
}
}
}