Files
lattice/core/rgsw/blindrot/batch_evaluate.go
T
zeekay 3e5fd84d34 feat(blindrot): BatchEvaluate parallel API for N independent blind rotations
Fans N independent (*Evaluator).Evaluate calls across a goroutine pool
of GOMAXPROCS workers. Each worker owns a fresh NewEvaluator clone
because the receiver carries mutable scratch buffers (poolMod2N,
accumulator) and the package exposes no ShallowCopy. The BRK key set
is shared read-only.

Per-iteration output is byte-equal to a serial Evaluate call.

Measured 4.61x at N=16 on M1 Max P-cores with blindrot test params
(LogN=10/9, Q=0x7fff801). Geometry matches the 5.84x ceiling
documented in fhe/policy/PERFORMANCE.md section 4 at full bootstrap
params; the gap is the relative weight of per-worker NewEvaluator
allocation against the cheaper test bootstrap.

Tests:
- TestBatchEvaluate_ByteEqualSerial: N=16 byte-equal vs serial
- TestBatchEvaluate_Empty: nil in -> nil out
- TestBatchEvaluate_Single: N=1 equivalent to direct Evaluate
- TestBatchEvaluate_LengthMismatch: rejects mismatched len(testPolys)
- TestBatchEvaluate_RaceClean: passes -race
2026-04-27 14:32:11 -07:00

105 lines
3.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package blindrot
import (
"errors"
"runtime"
"sync"
"github.com/luxfi/lattice/v7/core/rlwe"
"github.com/luxfi/lattice/v7/ring"
)
// errMismatchedTestPolyLen is returned for every input slot when len(testPolys)
// does not match len(cts). The whole batch is rejected — failing per-slot here
// would mask a programmer error.
var errMismatchedTestPolyLen = errors.New("blindrot: BatchEvaluate: len(testPolys) != len(cts)")
// BatchEvaluate runs N independent blind-rotation evaluations in parallel.
//
// Each entry in cts is paired with the corresponding entry in testPolys; the
// per-iteration call is byte-equivalent to a serial Evaluate(ct, tp, BRK).
//
// Concurrency model: the receiver Evaluator carries mutable scratch buffers
// (poolMod2N, accumulator) and an embedded *rgsw.Evaluator with its own buffer
// pool. Those structures are NOT safe for concurrent use, and the package
// exposes no ShallowCopy. BatchEvaluate therefore allocates one fresh
// Evaluator per worker goroutine via NewEvaluator(paramsBR, paramsLWE) and
// fans the work out across runtime.GOMAXPROCS workers. The receiver Evaluator
// is read-only here (only paramsBR and paramsLWE are inspected for cloning).
//
// The BlindRotationEvaluationKeySet is treated as read-only and shared across
// goroutines: GetBlindRotationKey / GetEvaluationKeySet must return the same
// keys for the same indices (the in-tree MemBlindRotationEvaluationKeySet
// satisfies this — it is a fixed slice of pointers).
//
// Per PERFORMANCE.md §4 (luxfi/fhe), the M1 Max P-core ceiling is ~5.84× at
// N≥16; the goroutine fan-out saturates the 8 P-cores. The Metal batch path
// is excluded by design until the metal_batch_bootstrap kernel ships in
// luxcpp/lattice/src/metal/ (PERFORMANCE.md §6 G3). When it lands, this
// function gains a fast path that dispatches the whole batch to one Metal
// command buffer instead of fanning out goroutines.
func (eval *Evaluator) BatchEvaluate(
cts []*rlwe.Ciphertext,
testPolys []map[int]*ring.Poly,
BRK BlindRotationEvaluationKeySet,
) ([]map[int]*rlwe.Ciphertext, []error) {
n := len(cts)
if n == 0 {
return nil, nil
}
if len(testPolys) != n {
errs := make([]error, n)
for i := range errs {
errs[i] = errMismatchedTestPolyLen
}
return make([]map[int]*rlwe.Ciphertext, n), errs
}
out := make([]map[int]*rlwe.Ciphertext, n)
errs := make([]error, n)
workers := runtime.GOMAXPROCS(0)
if workers < 1 {
workers = 1
}
if workers > n {
workers = n
}
// Single-worker fast path — fall through to a plain serial loop on the
// receiver. Avoids the cost of spinning up a worker pool for n=1.
if workers == 1 {
for i := range cts {
out[i], errs[i] = eval.Evaluate(cts[i], testPolys[i], BRK)
}
return out, errs
}
// Fan-out: each worker owns one fresh Evaluator. Re-creating an Evaluator
// is cheap relative to a single Evaluate call (one ring poly pair + one
// ciphertext + the discrete-log map; ~N entries). The BRK is shared.
jobs := make(chan int, n)
for i := 0; i < n; i++ {
jobs <- i
}
close(jobs)
paramsBR := eval.paramsBR
paramsLWE := eval.paramsLWE
var wg sync.WaitGroup
wg.Add(workers)
for w := 0; w < workers; w++ {
go func() {
defer wg.Done()
worker := NewEvaluator(paramsBR, paramsLWE)
for i := range jobs {
out[i], errs[i] = worker.Evaluate(cts[i], testPolys[i], BRK)
}
}()
}
wg.Wait()
return out, errs
}