mirror of
https://github.com/luxfi/lattice.git
synced 2026-09-24 00:25:44 +00:00
Go-side surface for the Montgomery Metal NTT shipped in
luxcpp/lattice. New constructors:
- gpu.NewMontgomeryNTTContext(s *ring.SubRing): binds a SubRing to a
GPU-resident NTT context, capturing RootsForward/RootsBackward/
NInv/MRedConstant. Forward/Backward output is byte-equal to
ring.SubRing.NTT/INTT for the same inputs.
- gpu.RegisterSubRing / LookupSubRing / UnregisterSubRing: per-SubRing
registry so the Ring.NTT fast-path can route calls to GPU.
- gpu.SetNTTThreshold / NTTThreshold: tunable per-N crossover.
ring.SubRing now consults pluggable NTT dispatchers via
ring.SetGPUDispatchers (called from gpu/init). Default behaviour is
unchanged -- dispatcher returns false unless a SubRing was explicitly
registered AND the threshold is non-zero. Pure-Go path remains the
default.
Tests:
- TestMontgomery_ByteEqual_Forward: 1024 random vectors x N in
{1024, 2048, 4096} byte-equal to ring.SubRing.NTT
- TestMontgomery_ByteEqual_Inverse: same for INTT
- TestMontgomery_RoundTrip: at N up to 8192
- TestBatchNTT_NoSegfault: previously-segfaulting (N=4096, B=128) now
runs and produces byte-equal output to per-poly Go pure-Go.
Bench (M1 Max, benchtime=10x):
- N=4096 B=128 batched: 12.6 us per NTT (4.57x faster than
Go pure-Go)
- N=4096 B=64 batched: 18.2 us per NTT (3.18x faster)
- Single-poly Metal remains slower than Go at all measured N
(8-100x slower); single-poly dispatch is opt-in via threshold.
Also fixes a pre-existing bug in gpu.go pure-Go NewNTTContext:
ring.NewSubRing alone does not generate NTT constants; switched to
ring.NewRing which calls generateNTTConstants under the hood.
279 lines
8.8 KiB
Go
279 lines
8.8 KiB
Go
//go:build cgo && gpu
|
|
|
|
// Montgomery-form GPU dispatch.
|
|
//
|
|
// This file exposes the parts of the GPU NTT that match the Lattigo
|
|
// contract: SubRing.RootsForward / RootsBackward / NInv / MRedConstant
|
|
// flow through unchanged. Output of forward/inverse is byte-equal to
|
|
// ring.NTTStandard / ring.INTTStandard for the same SubRing.
|
|
//
|
|
// The Ring.NTT fast-path (subring_ops.go) consults Available() and
|
|
// SubRingNTTForward/Backward to opt into GPU dispatch.
|
|
//
|
|
// Copyright (c) 2024-2025 Lux Industries Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
package gpu
|
|
|
|
/*
|
|
#include <stdint.h>
|
|
#include <stdlib.h>
|
|
|
|
typedef struct LatticeNTTContext LatticeNTTContext;
|
|
|
|
LatticeNTTContext* lattice_ntt_create_montgomery(uint32_t N, uint64_t Q,
|
|
uint64_t mred_constant,
|
|
uint64_t n_inv,
|
|
const uint64_t* roots_forward,
|
|
const uint64_t* roots_backward);
|
|
void lattice_ntt_destroy(LatticeNTTContext* ctx);
|
|
int lattice_ntt_forward(LatticeNTTContext* ctx, uint64_t* data, uint32_t batch);
|
|
int lattice_ntt_inverse(LatticeNTTContext* ctx, uint64_t* data, uint32_t batch);
|
|
*/
|
|
import "C"
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"unsafe"
|
|
|
|
"github.com/luxfi/lattice/v7/ring"
|
|
)
|
|
|
|
// MontgomeryNTTContext binds a luxfi/lattice/v7/ring.SubRing to a
|
|
// GPU-resident NTT context. The output of Forward and Backward is
|
|
// byte-equal to the pure-Go ring.SubRing.NTT and INTT for the same
|
|
// inputs.
|
|
type MontgomeryNTTContext struct {
|
|
ptr *C.LatticeNTTContext
|
|
N uint32
|
|
Q uint64
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewMontgomeryNTTContext binds to a SubRing. Captures Montgomery roots
|
|
// at construction time -- subsequent SubRing mutations are not seen.
|
|
func NewMontgomeryNTTContext(s *ring.SubRing) (*MontgomeryNTTContext, error) {
|
|
if s == nil {
|
|
return nil, fmt.Errorf("nil SubRing")
|
|
}
|
|
if s.NTTTable == nil {
|
|
return nil, fmt.Errorf("SubRing.NTTTable is nil -- call ring.NewRing first")
|
|
}
|
|
if s.N <= 0 {
|
|
return nil, fmt.Errorf("invalid SubRing.N=%d", s.N)
|
|
}
|
|
if len(s.RootsForward) < s.N || len(s.RootsBackward) < s.N {
|
|
return nil, fmt.Errorf(
|
|
"SubRing roots length mismatch: have RootsForward=%d RootsBackward=%d, need >= N=%d",
|
|
len(s.RootsForward), len(s.RootsBackward), s.N)
|
|
}
|
|
|
|
N := uint32(s.N)
|
|
Q := s.Modulus
|
|
|
|
// The ring stores bit-reversed roots in arrays of length N (NthRoot/2
|
|
// where NthRoot = 2N for the Standard ring). The C ABI expects a
|
|
// length-N array indexed exactly the same way the butterfly loop
|
|
// indexes it: `roots[m+i]` where `m+i` ranges over [1, N).
|
|
rfwd := s.RootsForward[:N]
|
|
rbwd := s.RootsBackward[:N]
|
|
|
|
ptr := C.lattice_ntt_create_montgomery(
|
|
C.uint32_t(N),
|
|
C.uint64_t(Q),
|
|
C.uint64_t(s.MRedConstant),
|
|
C.uint64_t(s.NInv),
|
|
(*C.uint64_t)(unsafe.Pointer(&rfwd[0])),
|
|
(*C.uint64_t)(unsafe.Pointer(&rbwd[0])),
|
|
)
|
|
if ptr == nil {
|
|
return nil, fmt.Errorf("lattice_ntt_create_montgomery failed for N=%d Q=%d", N, Q)
|
|
}
|
|
|
|
return &MontgomeryNTTContext{ptr: ptr, N: N, Q: Q}, nil
|
|
}
|
|
|
|
// Close releases the GPU context.
|
|
func (ctx *MontgomeryNTTContext) Close() {
|
|
ctx.mu.Lock()
|
|
defer ctx.mu.Unlock()
|
|
if ctx.ptr != nil {
|
|
C.lattice_ntt_destroy(ctx.ptr)
|
|
ctx.ptr = nil
|
|
}
|
|
}
|
|
|
|
// Forward in-place. data must have length batch*N.
|
|
func (ctx *MontgomeryNTTContext) Forward(data []uint64, batch uint32) error {
|
|
ctx.mu.RLock()
|
|
defer ctx.mu.RUnlock()
|
|
if ctx.ptr == nil {
|
|
return fmt.Errorf("context closed")
|
|
}
|
|
if int(batch)*int(ctx.N) > len(data) {
|
|
return fmt.Errorf("buffer too small: need %d, got %d",
|
|
int(batch)*int(ctx.N), len(data))
|
|
}
|
|
rc := C.lattice_ntt_forward(ctx.ptr, (*C.uint64_t)(unsafe.Pointer(&data[0])), C.uint32_t(batch))
|
|
if rc != 0 {
|
|
return fmt.Errorf("lattice_ntt_forward returned %d", int(rc))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Backward in-place.
|
|
func (ctx *MontgomeryNTTContext) Backward(data []uint64, batch uint32) error {
|
|
ctx.mu.RLock()
|
|
defer ctx.mu.RUnlock()
|
|
if ctx.ptr == nil {
|
|
return fmt.Errorf("context closed")
|
|
}
|
|
if int(batch)*int(ctx.N) > len(data) {
|
|
return fmt.Errorf("buffer too small: need %d, got %d",
|
|
int(batch)*int(ctx.N), len(data))
|
|
}
|
|
rc := C.lattice_ntt_inverse(ctx.ptr, (*C.uint64_t)(unsafe.Pointer(&data[0])), C.uint32_t(batch))
|
|
if rc != 0 {
|
|
return fmt.Errorf("lattice_ntt_inverse returned %d", int(rc))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// SubRing dispatch fast-path
|
|
// =============================================================================
|
|
//
|
|
// Available() reports whether the GPU NTT is reachable on this build (cgo
|
|
// + GPU library + Metal/CUDA at runtime). NTTThreshold is the per-poly
|
|
// crossover: below this N, Go pure-Go always wins on this backend (Metal
|
|
// command-buffer overhead floor measured at ~12-15 µs vs <1 µs Go). At
|
|
// or above this N the Metal path is competitive on M1 Max; for batch
|
|
// dispatch the threshold drops further but is gated by the caller (the
|
|
// ring core only ever calls per-SubRing single-poly NTT).
|
|
|
|
// NTTThreshold is the minimum N for which a single-poly GPU dispatch is
|
|
// considered worthwhile. Tunable; based on direct measurement on M1 Max
|
|
// with the Montgomery kernel after the BatchNTT fix landed. Override at
|
|
// runtime via SetNTTThreshold.
|
|
var nttThreshold atomic.Uint32
|
|
|
|
func init() {
|
|
// 0 means "GPU dispatch disabled by default for single-poly". The
|
|
// fast-path remains opt-in until a caller explicitly registers a
|
|
// MontgomeryNTTContext for a given SubRing -- see RegisterSubRing.
|
|
// Single-poly Metal NTT is strictly slower than Go on M1 Max for
|
|
// every measured N up to 16384, so we do NOT auto-dispatch.
|
|
nttThreshold.Store(0)
|
|
|
|
// Register the dispatchers with the ring package so SubRing.NTT and
|
|
// SubRing.INTT consult our fast-path. Pure-Go behaviour is preserved
|
|
// for any SubRing that has not been explicitly registered.
|
|
ring.SetGPUDispatchers(NTTSubRingForward, NTTSubRingBackward)
|
|
}
|
|
|
|
// SetNTTThreshold overrides the single-poly dispatch threshold. Pass 0
|
|
// to disable single-poly GPU dispatch entirely (still allows batched
|
|
// dispatch via NewMontgomeryNTTContext directly). Pass math.MaxUint32 to
|
|
// dispatch every NTT through GPU (test mode).
|
|
func SetNTTThreshold(n uint32) { nttThreshold.Store(n) }
|
|
|
|
// NTTThreshold returns the current threshold.
|
|
func NTTThreshold() uint32 { return nttThreshold.Load() }
|
|
|
|
// Available reports whether the GPU NTT path is reachable.
|
|
func Available() bool {
|
|
return GPUAvailable()
|
|
}
|
|
|
|
// SubRing -> Montgomery context registry. The ring fast-path consults
|
|
// this map to find a per-SubRing GPU context. Construction is the
|
|
// caller's responsibility (typically via fhe.NewRingWithGPU or test
|
|
// scaffolding).
|
|
var (
|
|
subRingRegistryMu sync.RWMutex
|
|
subRingRegistry = make(map[*ring.SubRing]*MontgomeryNTTContext)
|
|
)
|
|
|
|
// RegisterSubRing binds a Montgomery GPU context to a SubRing. Subsequent
|
|
// calls to NTTSubRingForward / NTTSubRingBackward for that SubRing will
|
|
// dispatch to the GPU. Returns an existing context if already bound.
|
|
func RegisterSubRing(s *ring.SubRing) (*MontgomeryNTTContext, error) {
|
|
subRingRegistryMu.Lock()
|
|
defer subRingRegistryMu.Unlock()
|
|
if existing, ok := subRingRegistry[s]; ok && existing != nil {
|
|
return existing, nil
|
|
}
|
|
ctx, err := NewMontgomeryNTTContext(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
subRingRegistry[s] = ctx
|
|
return ctx, nil
|
|
}
|
|
|
|
// UnregisterSubRing removes the binding and closes the underlying GPU
|
|
// context.
|
|
func UnregisterSubRing(s *ring.SubRing) {
|
|
subRingRegistryMu.Lock()
|
|
defer subRingRegistryMu.Unlock()
|
|
if ctx, ok := subRingRegistry[s]; ok {
|
|
ctx.Close()
|
|
delete(subRingRegistry, s)
|
|
}
|
|
}
|
|
|
|
// LookupSubRing returns the bound GPU context, or nil if none.
|
|
func LookupSubRing(s *ring.SubRing) *MontgomeryNTTContext {
|
|
subRingRegistryMu.RLock()
|
|
defer subRingRegistryMu.RUnlock()
|
|
return subRingRegistry[s]
|
|
}
|
|
|
|
// NTTSubRingForward dispatches a forward NTT through the registered
|
|
// GPU context for s. Returns false if no context is registered or N is
|
|
// below the threshold; the caller should then run the pure-Go path.
|
|
func NTTSubRingForward(s *ring.SubRing, src, dst []uint64) bool {
|
|
if !Available() {
|
|
return false
|
|
}
|
|
thr := nttThreshold.Load()
|
|
if thr == 0 || uint32(s.N) < thr {
|
|
return false
|
|
}
|
|
ctx := LookupSubRing(s)
|
|
if ctx == nil {
|
|
return false
|
|
}
|
|
if &src[0] != &dst[0] {
|
|
copy(dst, src)
|
|
}
|
|
if err := ctx.Forward(dst, 1); err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// NTTSubRingBackward dispatches an inverse NTT through the registered
|
|
// GPU context.
|
|
func NTTSubRingBackward(s *ring.SubRing, src, dst []uint64) bool {
|
|
if !Available() {
|
|
return false
|
|
}
|
|
thr := nttThreshold.Load()
|
|
if thr == 0 || uint32(s.N) < thr {
|
|
return false
|
|
}
|
|
ctx := LookupSubRing(s)
|
|
if ctx == nil {
|
|
return false
|
|
}
|
|
if &src[0] != &dst[0] {
|
|
copy(dst, src)
|
|
}
|
|
if err := ctx.Backward(dst, 1); err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|