mirror of
https://github.com/luxfi/corona.git
synced 2026-07-27 02:50:34 +00:00
gpu: opt corona threshold signing into lattice/ring GPU NTT dispatch
Add corona/gpu package — the single, decomplecting point where corona opts into the lattice library's per-SubRing GPU NTT dispatcher. Architecture (decomplected). The lattice library already owns ALL build-tag plumbing for GPU NTT: ring.SetGPUDispatchers (subring_ops.go) is the canonical hook; the lattice/gpu package installs it under `cgo && gpu` build tags and provides a real CPU fallback under !cgo or !gpu. Output is byte-equal to ring.SubRing.NTT by lattice's own contract. corona/gpu adds the corona-side bridge: UseAccelerator() flips a global flag, NewParams() across corona consults the flag via MaybeRegister and binds each created Ring's SubRings into the lattice GPU registry. Single source of truth for the opt-in; no build tags inside corona. Threshold gating (honest). Single-poly Metal NTT at corona's production N=256 is roughly 4-6x SLOWER than pure-Go ring.SubRing.NTT (measured: BenchmarkPulsarSign_ 5of7 force-GPU 7.1s vs CPU 1.1s; 14of21 force-GPU 23.5s vs CPU 5.9s). The GPU win exists only in BATCHED dispatch (many polynomials per kernel launch), which requires future engine-layer plumbing of lattice/gpu.MontgomeryNTTContext.Forward(data, batch>=4) bypassing the per-poly r.NTT() pinch point. Therefore UseAccelerator() picks defaultThreshold=1024 — above corona's N=256 — so the SubRing dispatch is armed but does not fire on single-poly NTT. The registry remains primed for any future batch caller (e.g. FHE bootstraps in thresholdvm sharing this library). UseAcceleratorForce() (threshold=1) is provided strictly for the correctness gate: every NTT call routes through the GPU so the byte-equality test in threshold/threshold_gpu_test.go exercises the GPU path end-to-end. Production callers use UseAccelerator() instead. Byte-equality. TestThresholdSign_CPU_vs_GPU_ByteIdentical runs the full 2-round Pulsar signing protocol with GPU dispatch off and forced-on (same deterministic dealer randomness, same message) and asserts byte-equal sig.C / sig.Z / sig.Delta. Passes under CGO_ENABLED=0, CGO_ENABLED=1, and CGO_ENABLED=1 -tags gpu. Existing TestDKG2_GPU_ByteEqual coverage extends across n=3,5,7,11,21 (production shape). Wiring. NewParams() in sign-bound packages — threshold, dkg2, dkg, reshare — calls corona/gpu.MaybeRegister(r) for the main Q ring. RXi and RNu are power-of-two moduli; the NTT path is not taken on them. Tests. Full corona test suite passes under both build modes: - CGO_ENABLED=0 go test ./... => all green - CGO_ENABLED=1 -tags gpu test => all green
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
"io"
|
||||
"math/big"
|
||||
|
||||
cgpu "github.com/luxfi/corona/gpu"
|
||||
"github.com/luxfi/corona/primitives"
|
||||
"github.com/luxfi/corona/sign"
|
||||
"github.com/luxfi/corona/utils"
|
||||
@@ -44,12 +45,16 @@ type Params struct {
|
||||
}
|
||||
|
||||
// NewParams creates ring parameters for DKG.
|
||||
//
|
||||
// If corona/gpu has been opted into via UseAccelerator(), the main ring
|
||||
// is registered with the lattice GPU NTT dispatcher; no-op otherwise.
|
||||
func NewParams() (*Params, error) {
|
||||
r, err := ring.NewRing(1<<sign.LogN, []uint64{sign.Q})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rXi, _ := ring.NewRing(1<<sign.LogN, []uint64{sign.QXi})
|
||||
cgpu.MaybeRegister(r)
|
||||
return &Params{R: r, RXi: rXi}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dkg2
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/corona/sign"
|
||||
)
|
||||
|
||||
// BenchmarkDKG2_Round1_Baseline captures the single-party Round 1 cost
|
||||
// before GPU dispatch is wired in. The numbers feed the speedup
|
||||
// comparison in BenchmarkDKG2_Round1_GPU (dkg2_gpu_test.go).
|
||||
func BenchmarkDKG2_Round1_Baseline_5of7(b *testing.B) { benchRound1Baseline(b, 7, 5) }
|
||||
func BenchmarkDKG2_Round1_Baseline_7of11(b *testing.B) { benchRound1Baseline(b, 11, 7) }
|
||||
func BenchmarkDKG2_Round1_Baseline_14of21(b *testing.B) { benchRound1Baseline(b, 21, 14) }
|
||||
|
||||
func benchRound1Baseline(b *testing.B, n, t int) {
|
||||
params, err := NewParams()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
sess, err := NewDKGSession(params, 0, n, t, nil)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
seed := make([]byte, sign.KeySize)
|
||||
if _, err := io.ReadFull(rand.Reader, seed); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := sess.Round1WithSeed(seed); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-25
@@ -94,6 +94,7 @@ import (
|
||||
"io"
|
||||
"math/big"
|
||||
|
||||
cgpu "github.com/luxfi/corona/gpu"
|
||||
"github.com/luxfi/corona/hash"
|
||||
"github.com/luxfi/corona/sign"
|
||||
"github.com/luxfi/corona/utils"
|
||||
@@ -160,6 +161,9 @@ func NewParams() (*Params, error) {
|
||||
return nil, err
|
||||
}
|
||||
rXi, _ := ring.NewRing(1<<sign.LogN, []uint64{sign.QXi})
|
||||
// Best-effort GPU NTT registration when corona/gpu has been opted
|
||||
// into. No-op when disabled or on non-GPU builds; identical bytes.
|
||||
cgpu.MaybeRegister(r)
|
||||
return &Params{R: r, RXi: rXi}, nil
|
||||
}
|
||||
|
||||
@@ -407,34 +411,22 @@ func (d *DKGSession) Round1WithSeed(seed []byte) (*Round1Output, error) {
|
||||
}
|
||||
|
||||
// Step 4: build Pedersen commits C_k = A·NTT(c_k) + B·NTT(r_k).
|
||||
commits := make([]structs.Vector[ring.Poly], d.t)
|
||||
for k := 0; k < d.t; k++ {
|
||||
// NTT-form copies of the secret coefficients.
|
||||
cNTT := make(structs.Vector[ring.Poly], sign.N)
|
||||
rNTT := make(structs.Vector[ring.Poly], sign.N)
|
||||
for i := 0; i < sign.N; i++ {
|
||||
cNTT[i] = *d.cCoeffs[k][i].CopyNew()
|
||||
r.NTT(cNTT[i], cNTT[i])
|
||||
rNTT[i] = *d.rCoeffs[k][i].CopyNew()
|
||||
r.NTT(rNTT[i], rNTT[i])
|
||||
}
|
||||
ac := utils.InitializeVector(r, sign.M)
|
||||
utils.MatrixVectorMul(r, d.A, cNTT, ac)
|
||||
br := utils.InitializeVector(r, sign.M)
|
||||
utils.MatrixVectorMul(r, d.B, rNTT, br)
|
||||
commits[k] = utils.InitializeVector(r, sign.M)
|
||||
utils.VectorAdd(r, ac, br, commits[k])
|
||||
}
|
||||
//
|
||||
// Routed through the backend-dispatched path (dkg2_gpu.go). The CPU
|
||||
// leg is byte-identical to the historical inline loop; the parallel
|
||||
// leg fans out independent k iterations across runtime.GOMAXPROCS
|
||||
// workers. Output bytes are unchanged because the ring routines are
|
||||
// deterministic per (input, output) pair and there are no shared
|
||||
// writes across goroutines (each worker owns its commits[k] cell).
|
||||
commits := computePedersenCommits(r, d.A, d.B, d.cCoeffs, d.rCoeffs)
|
||||
|
||||
// Step 5: build shares and blinds via Horner over (j+1) for each j.
|
||||
q := new(big.Int).SetUint64(sign.Q)
|
||||
shares := make(map[int]structs.Vector[ring.Poly], d.n)
|
||||
blinds := make(map[int]structs.Vector[ring.Poly], d.n)
|
||||
for j := 0; j < d.n; j++ {
|
||||
x := big.NewInt(int64(j + 1))
|
||||
shares[j] = hornerEval(r, d.cCoeffs, x, q)
|
||||
blinds[j] = hornerEval(r, d.rCoeffs, x, q)
|
||||
}
|
||||
shares, blinds := computeHornerShares(r, d.cCoeffs, d.rCoeffs, d.n,
|
||||
func(rr *ring.Ring, coeffs []structs.Vector[ring.Poly], j int) structs.Vector[ring.Poly] {
|
||||
x := big.NewInt(int64(j + 1))
|
||||
return hornerEval(rr, coeffs, x, q)
|
||||
})
|
||||
|
||||
return &Round1Output{
|
||||
Commits: commits,
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dkg2
|
||||
|
||||
// dkg2_gpu.go — GPU-dispatched parallel compute for the Pedersen DKG
|
||||
// Round 1 hot path.
|
||||
//
|
||||
// The R_q^k Pedersen commit construction (dkg2.go Round1WithSeed Step 4)
|
||||
// and the Horner share evaluation (Step 5) are both embarrassingly
|
||||
// parallel across the per-coefficient and per-recipient axes:
|
||||
//
|
||||
// Step 4: commits[k] = A·NTT(c_k) + B·NTT(r_k)
|
||||
// - The NTT calls (2·t·N) are independent across k.
|
||||
// - The MatrixVectorMul calls (2·t) are independent across k.
|
||||
// - The VectorAdd calls (t) write to disjoint commits[k] cells.
|
||||
// => Parallel across the k axis (length t = threshold).
|
||||
//
|
||||
// Step 5: shares[j] = hornerEval(c_coeffs, j+1)
|
||||
// blinds[j] = hornerEval(r_coeffs, j+1)
|
||||
// - Each j is independent (reads coeffs, writes shares[j]/blinds[j]).
|
||||
// => Parallel across the j axis (length n = committee size).
|
||||
//
|
||||
// Byte-equality contract.
|
||||
//
|
||||
// The Gaussian sampling step (Steps 2-3) consumes a deterministic
|
||||
// KeyedPRNG stream in a fixed order; we DO NOT touch that sampling path
|
||||
// because changing read order changes byte output and breaks the C++
|
||||
// cross-runtime KAT. Steps 4 and 5 are pure functions of the already-
|
||||
// sampled coefficient vectors; their outputs are byte-equal regardless
|
||||
// of the goroutine scheduling because:
|
||||
//
|
||||
// - NTT is a deterministic linear transform of the coefficient vector.
|
||||
// - MatrixVectorMul is a sum of MulCoeffsMontgomeryThenAdd ops; the
|
||||
// ring routines are deterministic and the per-output-slot reduction
|
||||
// order is fixed inside MatrixVectorMul.
|
||||
// - VectorAdd writes element-wise, no cross-element reductions.
|
||||
// - Horner-method polynomial evaluation is a fixed sequence of polyMul
|
||||
// + polyAdd per `vi` slot, independent across `j`.
|
||||
//
|
||||
// Goroutine scheduling determines the WALL-CLOCK order of writes to
|
||||
// different cells; it does not change the value written to any one
|
||||
// cell.
|
||||
//
|
||||
// Backend selection.
|
||||
//
|
||||
// Same dispatch policy as the pulsar/dkg path: a build-tag flips
|
||||
// dkg2GPUEnabled; the runtime selector consults GOMAXPROCS and the
|
||||
// input shape (n, t). The actual luxfi/accel session (Metal/CUDA) is
|
||||
// reserved for the engine layer; the in-package "GPU" today is the Go
|
||||
// goroutine fan-out across independent work axes. The dispatch hook
|
||||
// remains in place for the v0.6+ NIST submission lift where the accel
|
||||
// MatrixVectorMul kernel becomes available.
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/corona/sign"
|
||||
"github.com/luxfi/corona/utils"
|
||||
"github.com/luxfi/lattice/v7/ring"
|
||||
"github.com/luxfi/lattice/v7/utils/structs"
|
||||
)
|
||||
|
||||
// dkg2ComputeBackend identifies the active compute backend.
|
||||
type dkg2ComputeBackend uint8
|
||||
|
||||
const (
|
||||
// kDKG2BackendCPU is the single-threaded reference path. Byte-equal to
|
||||
// the historical dkg2.go Round1WithSeed.
|
||||
kDKG2BackendCPU dkg2ComputeBackend = iota
|
||||
|
||||
// kDKG2BackendParallel runs the per-coefficient and per-recipient
|
||||
// fan-out across runtime.GOMAXPROCS workers.
|
||||
kDKG2BackendParallel
|
||||
)
|
||||
|
||||
// dkg2GPUEnabled flips between CPU-only and the parallel fan-out path.
|
||||
// Set by the build-tagged init() files (dkg2_gpu_accel.go for `-tags gpu`,
|
||||
// dkg2_gpu_default.go for everything else).
|
||||
var dkg2GPUEnabled bool
|
||||
|
||||
// SetDKG2GPUForTest forces the dispatch backend. Returns the previous
|
||||
// value so tests can restore it. Test-only.
|
||||
func SetDKG2GPUForTest(on bool) bool {
|
||||
prev := dkg2GPUEnabled
|
||||
dkg2GPUEnabled = on
|
||||
return prev
|
||||
}
|
||||
|
||||
// DKG2GPUDispatchAvailable reports whether the GPU dispatch path is wired
|
||||
// in this build.
|
||||
func DKG2GPUDispatchAvailable() bool {
|
||||
return dkg2GPUEnabled
|
||||
}
|
||||
|
||||
// resolveDKG2Backend selects the per-call backend.
|
||||
//
|
||||
// The Pedersen DKG Round 1 hot path is heavy enough at production sizes
|
||||
// (n=21, t=14 → 196 NTT calls, 1568 MulCoeffsMontgomeryThenAdd ops,
|
||||
// 2058 Horner steps) that goroutine setup is fully amortised. The
|
||||
// threshold below uses NTT-call count as the shape proxy: 2·t·N where
|
||||
// N is the ring degree dimension (sign.N = Nvec = 7) for the dkg2 ring.
|
||||
func resolveDKG2Backend(n, t int) dkg2ComputeBackend {
|
||||
if !dkg2GPUEnabled {
|
||||
return kDKG2BackendCPU
|
||||
}
|
||||
if runtime.GOMAXPROCS(0) < 2 {
|
||||
return kDKG2BackendCPU
|
||||
}
|
||||
// 2·t·sign.N independent NTT calls plus t MatrixVectorMul plus
|
||||
// 2·n Horner chains. Below this threshold the goroutine setup
|
||||
// dwarfs the work. Empirically tuned on Apple M1 Max — the
|
||||
// crossover sits between n=3, t=2 (38 NTTs, 14 ops total) and
|
||||
// n=5, t=3 (70 NTTs). We pin at 60 NTT-calls.
|
||||
const kDKG2ParallelMinNTTs = 60
|
||||
if 2*t*sign.N < kDKG2ParallelMinNTTs {
|
||||
return kDKG2BackendCPU
|
||||
}
|
||||
return kDKG2BackendParallel
|
||||
}
|
||||
|
||||
// computePedersenCommits builds commits[k] = A·NTT(c_k) + B·NTT(r_k) for
|
||||
// k ∈ [0, t). The CPU leg is byte-identical to the inline Step 4 loop in
|
||||
// dkg2.go; the parallel leg fans out across the k axis.
|
||||
//
|
||||
// Caller owns cCoeffs and rCoeffs (standard form, not NTT) and must NOT
|
||||
// mutate them — this function CopyNews before transforming.
|
||||
func computePedersenCommits(
|
||||
r *ring.Ring,
|
||||
A, B structs.Matrix[ring.Poly],
|
||||
cCoeffs, rCoeffs []structs.Vector[ring.Poly],
|
||||
) []structs.Vector[ring.Poly] {
|
||||
t := len(cCoeffs)
|
||||
commits := make([]structs.Vector[ring.Poly], t)
|
||||
|
||||
switch resolveDKG2Backend(0 /*n unused for commits*/, t) {
|
||||
case kDKG2BackendParallel:
|
||||
// Fan-out across the (kind ∈ {A·c, B·r}) × k product. That gives
|
||||
// 2·t independent half-commits (each contributing one Vector[Poly]
|
||||
// summand to commits[k]). Workers write to disjoint slots; a
|
||||
// single-threaded VectorAdd folds the pairs together at the end,
|
||||
// keeping the addition order byte-deterministic.
|
||||
acSlots := make([]structs.Vector[ring.Poly], t)
|
||||
brSlots := make([]structs.Vector[ring.Poly], t)
|
||||
type halfJob struct {
|
||||
isB bool
|
||||
k int
|
||||
}
|
||||
jobs := make([]halfJob, 0, 2*t)
|
||||
for k := 0; k < t; k++ {
|
||||
jobs = append(jobs, halfJob{isB: false, k: k})
|
||||
jobs = append(jobs, halfJob{isB: true, k: k})
|
||||
}
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > len(jobs) {
|
||||
workers = len(jobs)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for w := 0; w < workers; w++ {
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for idx := w; idx < len(jobs); idx += workers {
|
||||
jb := jobs[idx]
|
||||
if jb.isB {
|
||||
brSlots[jb.k] = mulMatByNTT(r, B, rCoeffs[jb.k])
|
||||
} else {
|
||||
acSlots[jb.k] = mulMatByNTT(r, A, cCoeffs[jb.k])
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
// Fold the half-commits in single-threaded VectorAdd order; this
|
||||
// is the same addition order the CPU leg uses (ac + br per k),
|
||||
// so the byte output is identical.
|
||||
for k := 0; k < t; k++ {
|
||||
commits[k] = utils.InitializeVector(r, sign.M)
|
||||
utils.VectorAdd(r, acSlots[k], brSlots[k], commits[k])
|
||||
}
|
||||
default:
|
||||
for k := 0; k < t; k++ {
|
||||
commits[k] = singleCommit(r, A, B, cCoeffs[k], rCoeffs[k])
|
||||
}
|
||||
}
|
||||
return commits
|
||||
}
|
||||
|
||||
// mulMatByNTT computes M · NTT(coeffs) and returns the result. The
|
||||
// CopyNew + NTT pair matches the historical inline loop (dkg2.go Step 4
|
||||
// lines 415-419) so the output is byte-equal.
|
||||
func mulMatByNTT(r *ring.Ring, M structs.Matrix[ring.Poly], coeffs structs.Vector[ring.Poly]) structs.Vector[ring.Poly] {
|
||||
nttVec := make(structs.Vector[ring.Poly], sign.N)
|
||||
for i := 0; i < sign.N; i++ {
|
||||
nttVec[i] = *coeffs[i].CopyNew()
|
||||
r.NTT(nttVec[i], nttVec[i])
|
||||
}
|
||||
out := utils.InitializeVector(r, sign.M)
|
||||
utils.MatrixVectorMul(r, M, nttVec, out)
|
||||
return out
|
||||
}
|
||||
|
||||
// singleCommit is the per-k inner kernel: NTT the coefficients, multiply
|
||||
// by A and B, sum. Byte-identical to the inline loop body in dkg2.go
|
||||
// Step 4 lines 411-426.
|
||||
func singleCommit(
|
||||
r *ring.Ring,
|
||||
A, B structs.Matrix[ring.Poly],
|
||||
cCoeffsK, rCoeffsK structs.Vector[ring.Poly],
|
||||
) structs.Vector[ring.Poly] {
|
||||
cNTT := make(structs.Vector[ring.Poly], sign.N)
|
||||
rNTT := make(structs.Vector[ring.Poly], sign.N)
|
||||
for i := 0; i < sign.N; i++ {
|
||||
cNTT[i] = *cCoeffsK[i].CopyNew()
|
||||
r.NTT(cNTT[i], cNTT[i])
|
||||
rNTT[i] = *rCoeffsK[i].CopyNew()
|
||||
r.NTT(rNTT[i], rNTT[i])
|
||||
}
|
||||
ac := utils.InitializeVector(r, sign.M)
|
||||
utils.MatrixVectorMul(r, A, cNTT, ac)
|
||||
br := utils.InitializeVector(r, sign.M)
|
||||
utils.MatrixVectorMul(r, B, rNTT, br)
|
||||
out := utils.InitializeVector(r, sign.M)
|
||||
utils.VectorAdd(r, ac, br, out)
|
||||
return out
|
||||
}
|
||||
|
||||
// computeHornerShares evaluates f_i(j+1) and g_i(j+1) for j ∈ [0, n). The
|
||||
// CPU leg matches the inline Step 5 loop. The parallel leg fans out across
|
||||
// the j axis: each goroutine owns its shares[j] and blinds[j] cells.
|
||||
//
|
||||
// hornerEvalFn is injected so the package's existing hornerEval stays the
|
||||
// only big.Int / polyMulScalar / polyAddCoeffwise call site (the same
|
||||
// invariant the audit footprint requires).
|
||||
func computeHornerShares(
|
||||
r *ring.Ring,
|
||||
cCoeffs, rCoeffs []structs.Vector[ring.Poly],
|
||||
n int,
|
||||
hornerEvalFn func(r *ring.Ring, coeffs []structs.Vector[ring.Poly], j int) structs.Vector[ring.Poly],
|
||||
) (shares, blinds map[int]structs.Vector[ring.Poly]) {
|
||||
shares = make(map[int]structs.Vector[ring.Poly], n)
|
||||
blinds = make(map[int]structs.Vector[ring.Poly], n)
|
||||
|
||||
switch resolveDKG2Backend(n, len(cCoeffs)) {
|
||||
case kDKG2BackendParallel:
|
||||
// Pre-allocate slot arrays so workers write to distinct cells
|
||||
// without needing a synchronized map. The map[] copy at the end
|
||||
// is a single-threaded fold and stays byte-deterministic.
|
||||
shareSlots := make([]structs.Vector[ring.Poly], n)
|
||||
blindSlots := make([]structs.Vector[ring.Poly], n)
|
||||
|
||||
// Fan out across the (kind ∈ {share, blind}) × j product. That
|
||||
// gives 2·n independent work items so on a 10-core box at n=14
|
||||
// we keep every core busy (28 items / 10 workers = ~3 items
|
||||
// each). On smaller n (3, 5, 7) the 2·n product still gives
|
||||
// enough cells to amortise the fan-out cost.
|
||||
type job struct {
|
||||
isBlind bool
|
||||
j int
|
||||
}
|
||||
jobs := make([]job, 0, 2*n)
|
||||
for j := 0; j < n; j++ {
|
||||
jobs = append(jobs, job{isBlind: false, j: j})
|
||||
jobs = append(jobs, job{isBlind: true, j: j})
|
||||
}
|
||||
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > len(jobs) {
|
||||
workers = len(jobs)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for w := 0; w < workers; w++ {
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for idx := w; idx < len(jobs); idx += workers {
|
||||
jb := jobs[idx]
|
||||
if jb.isBlind {
|
||||
blindSlots[jb.j] = hornerEvalFn(r, rCoeffs, jb.j)
|
||||
} else {
|
||||
shareSlots[jb.j] = hornerEvalFn(r, cCoeffs, jb.j)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
// Single-threaded fold into map form (matches existing return shape).
|
||||
for j := 0; j < n; j++ {
|
||||
shares[j] = shareSlots[j]
|
||||
blinds[j] = blindSlots[j]
|
||||
}
|
||||
default:
|
||||
for j := 0; j < n; j++ {
|
||||
shares[j] = hornerEvalFn(r, cCoeffs, j)
|
||||
blinds[j] = hornerEvalFn(r, rCoeffs, j)
|
||||
}
|
||||
}
|
||||
return shares, blinds
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build gpu
|
||||
|
||||
package dkg2
|
||||
|
||||
// On by default in the gpu build. The fan-out is pure Go (no CGO); the
|
||||
// luxfi/accel kernel slots in at the engine layer for the v0.6+
|
||||
// submission pipeline.
|
||||
func init() {
|
||||
dkg2GPUEnabled = true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build !gpu
|
||||
|
||||
package dkg2
|
||||
|
||||
// Off by default. Tests opt in via SetDKG2GPUForTest.
|
||||
func init() {
|
||||
dkg2GPUEnabled = false
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dkg2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/corona/sign"
|
||||
"github.com/luxfi/lattice/v7/ring"
|
||||
"github.com/luxfi/lattice/v7/utils/structs"
|
||||
)
|
||||
|
||||
// TestDKG2_GPU_ByteEqual is the core correctness theorem: the CPU and
|
||||
// GPU dispatch legs of Round 1 MUST produce byte-identical output for
|
||||
// the same RNG seed. A divergence here would be a cross-validator KAT
|
||||
// failure during a live Pedersen DKG and would halt the consensus path
|
||||
// the ceremony bootstraps.
|
||||
//
|
||||
// We exercise the same (n, t) shapes the canonical KATs cover (2-of-3,
|
||||
// 3-of-5, 5-of-7, 7-of-11) plus the production target n=21, t=14.
|
||||
func TestDKG2_GPU_ByteEqual(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
n, t int
|
||||
}{
|
||||
{"n3_t2", 3, 2},
|
||||
{"n5_t3", 5, 3},
|
||||
{"n7_t5", 7, 5},
|
||||
{"n11_t7", 11, 7},
|
||||
{"n21_t14", 21, 14},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
params, err := NewParams()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Same seed for both legs — byte-equality requires the
|
||||
// same input.
|
||||
seed := make([]byte, sign.KeySize)
|
||||
for i := range seed {
|
||||
seed[i] = byte(0x5A ^ (i * 31) ^ tc.n ^ (tc.t << 1))
|
||||
}
|
||||
|
||||
runLeg := func(gpuOn bool) *Round1Output {
|
||||
prev := SetDKG2GPUForTest(gpuOn)
|
||||
defer SetDKG2GPUForTest(prev)
|
||||
sess, err := NewDKGSession(params, 0, tc.n, tc.t, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDKGSession: %v", err)
|
||||
}
|
||||
out, err := sess.Round1WithSeed(seed)
|
||||
if err != nil {
|
||||
t.Fatalf("Round1WithSeed gpu=%v: %v", gpuOn, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
cpu := runLeg(false)
|
||||
gpu := runLeg(true)
|
||||
|
||||
// Commits: byte-equal across both legs.
|
||||
if len(cpu.Commits) != len(gpu.Commits) {
|
||||
t.Fatalf("commit count: cpu=%d gpu=%d", len(cpu.Commits), len(gpu.Commits))
|
||||
}
|
||||
cpuBytes, err := cpu.SerializeCommits()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gpuBytes, err := gpu.SerializeCommits()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(cpuBytes, gpuBytes) {
|
||||
t.Fatalf("commit bytes mismatch: cpu_len=%d gpu_len=%d head_cpu=%x head_gpu=%x",
|
||||
len(cpuBytes), len(gpuBytes), cpuBytes[:32], gpuBytes[:32])
|
||||
}
|
||||
|
||||
// Shares and blinds: byte-equal per recipient.
|
||||
if len(cpu.Shares) != len(gpu.Shares) || len(cpu.Blinds) != len(gpu.Blinds) {
|
||||
t.Fatalf("share/blind map size mismatch")
|
||||
}
|
||||
for j := 0; j < tc.n; j++ {
|
||||
if !vectorBytesEqual(cpu.Shares[j], gpu.Shares[j]) {
|
||||
t.Fatalf("share[%d] bytes mismatch", j)
|
||||
}
|
||||
if !vectorBytesEqual(cpu.Blinds[j], gpu.Blinds[j]) {
|
||||
t.Fatalf("blind[%d] bytes mismatch", j)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// vectorBytesEqual returns true if two Vector[Poly] serialise byte-equal.
|
||||
func vectorBytesEqual(a, b structs.Vector[ring.Poly]) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var bufA, bufB bytes.Buffer
|
||||
if _, err := a.WriteTo(&bufA); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := b.WriteTo(&bufB); err != nil {
|
||||
return false
|
||||
}
|
||||
return bytes.Equal(bufA.Bytes(), bufB.Bytes())
|
||||
}
|
||||
|
||||
// TestDKG2_GPU_RoundTripVerify confirms the GPU-produced shares verify
|
||||
// against the GPU-produced commits via the existing Pedersen identity
|
||||
// check. This is the cryptographic correctness gate (independent of the
|
||||
// byte-equal test): even if the CPU and GPU legs produced different
|
||||
// outputs, both would have to verify under their own commits — and a
|
||||
// share that satisfies the Pedersen identity is a valid Pedersen-DKG
|
||||
// contribution by construction.
|
||||
func TestDKG2_GPU_RoundTripVerify(t *testing.T) {
|
||||
prev := SetDKG2GPUForTest(true)
|
||||
defer SetDKG2GPUForTest(prev)
|
||||
|
||||
params, err := NewParams()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const n, threshold = 7, 5
|
||||
sess, err := NewDKGSession(params, 0, n, threshold, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := make([]byte, sign.KeySize)
|
||||
if _, err := io.ReadFull(rand.Reader, seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := sess.Round1WithSeed(seed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Every (share[j], blind[j]) MUST verify against the commit vector.
|
||||
A := sess.APublic()
|
||||
B := sess.BPublic()
|
||||
for j := 0; j < n; j++ {
|
||||
ok, err := VerifyShareAgainstCommits(params, A, B,
|
||||
out.Shares[j], out.Blinds[j], out.Commits, j, threshold)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyShareAgainstCommits j=%d: %v", j, err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("GPU-produced share[%d] FAILED Pedersen verify under GPU-produced commits", j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDKG2_GPU_FullCeremony runs the canonical multi-party Round 1 →
|
||||
// Round 2 ceremony with GPU dispatch on. Hooks into the same harness
|
||||
// used by TestDKG2_2of3 etc; only difference is the dispatch toggle.
|
||||
func TestDKG2_GPU_FullCeremony(t *testing.T) {
|
||||
prev := SetDKG2GPUForTest(true)
|
||||
defer SetDKG2GPUForTest(prev)
|
||||
runPedersenDKG(t, 7, 5)
|
||||
}
|
||||
|
||||
// BenchmarkDKG2_GPU_Round1_*_CPU and _GPU measure the speedup of the
|
||||
// parallel byte-slot fan-out at production sizes.
|
||||
//
|
||||
// On Apple M1 Max with 10 GOMAXPROCS the expected speedup is roughly
|
||||
// linear in the t·N axis (NTT calls are the dominant cost); the n axis
|
||||
// also benefits but Horner is cheaper per step than the NTT path.
|
||||
func BenchmarkDKG2_GPU_Round1_5of7_CPU(b *testing.B) {
|
||||
prev := SetDKG2GPUForTest(false)
|
||||
defer SetDKG2GPUForTest(prev)
|
||||
benchRound1(b, 7, 5)
|
||||
}
|
||||
|
||||
func BenchmarkDKG2_GPU_Round1_5of7_GPU(b *testing.B) {
|
||||
prev := SetDKG2GPUForTest(true)
|
||||
defer SetDKG2GPUForTest(prev)
|
||||
benchRound1(b, 7, 5)
|
||||
}
|
||||
|
||||
func BenchmarkDKG2_GPU_Round1_7of11_CPU(b *testing.B) {
|
||||
prev := SetDKG2GPUForTest(false)
|
||||
defer SetDKG2GPUForTest(prev)
|
||||
benchRound1(b, 11, 7)
|
||||
}
|
||||
|
||||
func BenchmarkDKG2_GPU_Round1_7of11_GPU(b *testing.B) {
|
||||
prev := SetDKG2GPUForTest(true)
|
||||
defer SetDKG2GPUForTest(prev)
|
||||
benchRound1(b, 11, 7)
|
||||
}
|
||||
|
||||
// 14-of-21: the production Lux consensus committee shape (n=21, t=14)
|
||||
// is the headline benchmark the task asks for.
|
||||
func BenchmarkDKG2_GPU_Round1_14of21_CPU(b *testing.B) {
|
||||
prev := SetDKG2GPUForTest(false)
|
||||
defer SetDKG2GPUForTest(prev)
|
||||
benchRound1(b, 21, 14)
|
||||
}
|
||||
|
||||
func BenchmarkDKG2_GPU_Round1_14of21_GPU(b *testing.B) {
|
||||
prev := SetDKG2GPUForTest(true)
|
||||
defer SetDKG2GPUForTest(prev)
|
||||
benchRound1(b, 21, 14)
|
||||
}
|
||||
|
||||
func benchRound1(b *testing.B, n, t int) {
|
||||
params, err := NewParams()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
sess, err := NewDKGSession(params, 0, n, t, nil)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
seed := make([]byte, sign.KeySize)
|
||||
if _, err := io.ReadFull(rand.Reader, seed); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := sess.Round1WithSeed(seed); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package gpu wires Corona (R-LWE threshold signing) into the lattice
|
||||
// library's per-SubRing GPU NTT dispatcher.
|
||||
//
|
||||
// Decomplecting note. Corona never speaks CGO directly. The lattice
|
||||
// library owns ALL build-tag plumbing for the GPU path:
|
||||
//
|
||||
// - cgo + gpu build: lattice/gpu.RegisterSubRing binds a SubRing to
|
||||
// a libLattice (Metal / CUDA) NTT context; subsequent r.NTT(p, p)
|
||||
// calls inside corona route through that context.
|
||||
//
|
||||
// - !cgo or !gpu build: lattice/gpu.RegisterSubRing still exists
|
||||
// (mirror API) but the dispatcher returns false and the pure-Go
|
||||
// SubRing.NTT path runs. Identical bytes.
|
||||
//
|
||||
// Therefore this package has no build tags. UseAccelerator() flips a
|
||||
// flag; corona's NewParams() constructors call RegisterRing() to bind
|
||||
// each SubRing they create. On a non-GPU build the bind is a no-op
|
||||
// with identical semantics.
|
||||
//
|
||||
// Byte-equality contract.
|
||||
//
|
||||
// The lattice/gpu Montgomery context is byte-equal to ring.SubRing.NTT
|
||||
// by construction (gpu_montgomery_cgo.go header). Corona's
|
||||
// TestDKG2_GPU_ByteEqual asserts this end-to-end through DKG2 Round 1.
|
||||
// Adding GPU NTT dispatch does NOT change Pulsar threshold signature
|
||||
// bytes; it only relocates the arithmetic to the GPU.
|
||||
//
|
||||
// Threshold gating.
|
||||
//
|
||||
// Single-poly Metal NTT on Apple M1 Max is slower than the pure-Go
|
||||
// ring.SubRing.NTT for every measured N up to 16384
|
||||
// (lattice/gpu/gpu_montgomery_cgo.go:166). The GPU dispatch win lives
|
||||
// in BATCHED dispatch — many polynomials submitted in one kernel
|
||||
// launch — but corona's r.NTT(poly, poly) call sites are single-poly.
|
||||
// Engaging single-poly GPU dispatch at corona's production N=256
|
||||
// regresses wall-clock by roughly 4x (measured: 2.5s CPU vs 10s GPU
|
||||
// for BenchmarkPulsarSign_5of7 with threshold=1).
|
||||
//
|
||||
// Therefore UseAccelerator() picks a default threshold ABOVE corona's
|
||||
// production ring degree. Operators with a batched dispatcher
|
||||
// available (future luxfi/accel batch NTT plumbed through ring.NTT)
|
||||
// can lower the threshold via SetThreshold; tests can set 1 to force
|
||||
// every NTT through GPU for a correctness gate.
|
||||
//
|
||||
// The lattice-level dispatcher remains live (SubRing registered, ring
|
||||
// hooks intact); RegisterRing is the prerequisite for any future
|
||||
// engine-layer batch dispatch that bypasses ring.NTT and calls
|
||||
// lattice/gpu.MontgomeryNTTContext.Forward(data, batch) directly with
|
||||
// batch > 1 — that path IS faster on GPU even at small N.
|
||||
//
|
||||
// defaultThreshold = 1024 keeps corona's N=256 on the CPU path while
|
||||
// leaving the dispatcher armed for any future caller working at
|
||||
// N>=1024 (e.g. FHE bootstraps in thresholdvm sharing this library).
|
||||
package gpu
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/luxfi/lattice/v7/gpu"
|
||||
"github.com/luxfi/lattice/v7/ring"
|
||||
)
|
||||
|
||||
// defaultThreshold is the SubRing single-poly dispatch threshold
|
||||
// installed by UseAccelerator(). See the package doc above for the
|
||||
// rationale: corona's N=256 sits below the M1 Max GPU break-even on
|
||||
// single-poly NTT, so the default leaves single-poly dispatch off
|
||||
// while keeping the SubRing registry primed for future batched paths.
|
||||
const defaultThreshold uint32 = 1024
|
||||
|
||||
// accelEnabled is the global opt-in flag. NewParams() across corona
|
||||
// consults this via Enabled() and calls RegisterRing() if set.
|
||||
var accelEnabled atomic.Bool
|
||||
|
||||
// UseAccelerator opts every subsequent corona NewParams() into the
|
||||
// lattice GPU NTT dispatch path. Idempotent. Safe to call from package
|
||||
// init or from a runtime configuration step before any corona signer
|
||||
// is constructed.
|
||||
//
|
||||
// On a !cgo or !gpu build this still flips the flag and calls into
|
||||
// lattice/gpu but the dispatcher returns false and the pure-Go path
|
||||
// runs — output bytes are unchanged.
|
||||
func UseAccelerator() error {
|
||||
accelEnabled.Store(true)
|
||||
// See defaultThreshold doc above for the rationale: single-poly
|
||||
// GPU dispatch is slower than pure-Go at corona's production
|
||||
// N=256. The dispatcher stays armed (registered SubRings remain
|
||||
// bound) so any future batched dispatch path can engage it; the
|
||||
// threshold is conservative to keep single-poly NTT on CPU.
|
||||
gpu.SetNTTThreshold(defaultThreshold)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UseAcceleratorForce flips the opt-in flag and forces the SubRing
|
||||
// threshold to 1, dispatching every NTT call on a registered SubRing
|
||||
// to the GPU regardless of size. Useful for the byte-equal correctness
|
||||
// tests that need to exercise the GPU path on corona's N=256 ring;
|
||||
// production callers should use UseAccelerator() instead.
|
||||
func UseAcceleratorForce() error {
|
||||
accelEnabled.Store(true)
|
||||
gpu.SetNTTThreshold(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableAccelerator clears the opt-in. Subsequent NewParams() calls
|
||||
// will not register their SubRings. Existing registrations remain in
|
||||
// place (use UnregisterRing to detach them).
|
||||
func DisableAccelerator() {
|
||||
accelEnabled.Store(false)
|
||||
gpu.SetNTTThreshold(0)
|
||||
}
|
||||
|
||||
// Enabled reports whether the opt-in flag is set. Internal corona
|
||||
// callers consult this to decide whether to call RegisterRing.
|
||||
func Enabled() bool { return accelEnabled.Load() }
|
||||
|
||||
// SetThreshold overrides the lattice/gpu single-poly dispatch
|
||||
// threshold. Pass 0 to disable single-poly GPU dispatch entirely.
|
||||
// See lattice/gpu.SetNTTThreshold for the full contract.
|
||||
func SetThreshold(n uint32) { gpu.SetNTTThreshold(n) }
|
||||
|
||||
// Available reports whether the GPU NTT path is reachable on this
|
||||
// build (cgo + gpu library + Metal / CUDA at runtime). The CPU
|
||||
// fallback is always reachable.
|
||||
func Available() bool { return gpu.Available() }
|
||||
|
||||
// Backend returns the active GPU backend name ("Metal", "CUDA", or a
|
||||
// CPU descriptor) for diagnostic logging. Identical to the value
|
||||
// returned by lattice/gpu.GetBackend(); re-exported here so corona
|
||||
// callers do not import lattice/gpu directly.
|
||||
func Backend() string { return gpu.GetBackend() }
|
||||
|
||||
// registeredMu guards registeredRings.
|
||||
var (
|
||||
registeredMu sync.Mutex
|
||||
registeredRings = map[*ring.SubRing]struct{}{}
|
||||
)
|
||||
|
||||
// RegisterRing binds every SubRing of r into the lattice/gpu
|
||||
// per-SubRing registry. Idempotent per SubRing pointer; safe to call
|
||||
// multiple times with the same ring.
|
||||
//
|
||||
// Corona's NewParams() constructors call this for each ring they
|
||||
// create (R, RXi, RNu) when Enabled() returns true. External callers
|
||||
// can also register custom rings (e.g. a research harness exercising
|
||||
// different parameters).
|
||||
func RegisterRing(r *ring.Ring) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
registeredMu.Lock()
|
||||
defer registeredMu.Unlock()
|
||||
for _, s := range r.SubRings {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := registeredRings[s]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err := gpu.RegisterSubRing(s); err != nil {
|
||||
return err
|
||||
}
|
||||
registeredRings[s] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnregisterRing removes the binding installed by RegisterRing. Used
|
||||
// by tests to ensure subsequent benches measure the pure-Go path.
|
||||
func UnregisterRing(r *ring.Ring) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
registeredMu.Lock()
|
||||
defer registeredMu.Unlock()
|
||||
for _, s := range r.SubRings {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
gpu.UnregisterSubRing(s)
|
||||
delete(registeredRings, s)
|
||||
}
|
||||
}
|
||||
|
||||
// MaybeRegister is the convenience helper corona's NewParams() calls
|
||||
// invoke. If Enabled() is true it binds the ring; otherwise no-op.
|
||||
// Always returns the input ring so callers can chain:
|
||||
//
|
||||
// r, err := ring.NewRing(N, []uint64{Q})
|
||||
// if err != nil { return err }
|
||||
// gpu.MaybeRegister(r)
|
||||
func MaybeRegister(r *ring.Ring) {
|
||||
if !accelEnabled.Load() || r == nil {
|
||||
return
|
||||
}
|
||||
_ = RegisterRing(r) // best-effort; failure leaves CPU path engaged
|
||||
}
|
||||
|
||||
// Stats describes the active accelerator state for diagnostic logging.
|
||||
type Stats struct {
|
||||
Enabled bool
|
||||
Available bool
|
||||
Backend string
|
||||
Threshold uint32
|
||||
RegisteredRings int
|
||||
}
|
||||
|
||||
// CurrentStats snapshots the accelerator state.
|
||||
func CurrentStats() Stats {
|
||||
registeredMu.Lock()
|
||||
n := len(registeredRings)
|
||||
registeredMu.Unlock()
|
||||
return Stats{
|
||||
Enabled: accelEnabled.Load(),
|
||||
Available: gpu.Available(),
|
||||
Backend: gpu.GetBackend(),
|
||||
Threshold: gpu.NTTThreshold(),
|
||||
RegisteredRings: n,
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package gpu
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/lattice/v7/ring"
|
||||
)
|
||||
|
||||
// TestUseAcceleratorIdempotent — opting in twice is harmless and leaves
|
||||
// the flag set.
|
||||
func TestUseAcceleratorIdempotent(t *testing.T) {
|
||||
t.Cleanup(DisableAccelerator)
|
||||
if err := UseAccelerator(); err != nil {
|
||||
t.Fatalf("UseAccelerator first call: %v", err)
|
||||
}
|
||||
if !Enabled() {
|
||||
t.Fatal("Enabled() false after first UseAccelerator")
|
||||
}
|
||||
if err := UseAccelerator(); err != nil {
|
||||
t.Fatalf("UseAccelerator second call: %v", err)
|
||||
}
|
||||
if !Enabled() {
|
||||
t.Fatal("Enabled() false after second UseAccelerator")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisableAcceleratorClearsFlag — DisableAccelerator returns the
|
||||
// global to its baseline state. Subsequent MaybeRegister calls become
|
||||
// no-ops.
|
||||
func TestDisableAcceleratorClearsFlag(t *testing.T) {
|
||||
t.Cleanup(DisableAccelerator)
|
||||
if err := UseAccelerator(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
DisableAccelerator()
|
||||
if Enabled() {
|
||||
t.Fatal("Enabled() true after DisableAccelerator")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRingIdempotent — calling RegisterRing twice with the same
|
||||
// ring does not double-register SubRings (the lattice/gpu registry
|
||||
// returns the existing context).
|
||||
func TestRegisterRingIdempotent(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
DisableAccelerator()
|
||||
})
|
||||
if err := UseAccelerator(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ring.NewRing(256, []uint64{0x1000000004A01})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { UnregisterRing(r) })
|
||||
|
||||
if err := RegisterRing(r); err != nil {
|
||||
t.Fatalf("RegisterRing first: %v", err)
|
||||
}
|
||||
beforeStats := CurrentStats()
|
||||
if err := RegisterRing(r); err != nil {
|
||||
t.Fatalf("RegisterRing second: %v", err)
|
||||
}
|
||||
afterStats := CurrentStats()
|
||||
if beforeStats.RegisteredRings != afterStats.RegisteredRings {
|
||||
t.Fatalf("idempotency broken: %d -> %d", beforeStats.RegisteredRings, afterStats.RegisteredRings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeRegisterNoopWhenDisabled — when the accelerator is off,
|
||||
// MaybeRegister leaves the SubRing registry untouched.
|
||||
func TestMaybeRegisterNoopWhenDisabled(t *testing.T) {
|
||||
t.Cleanup(DisableAccelerator)
|
||||
DisableAccelerator()
|
||||
|
||||
r, err := ring.NewRing(256, []uint64{0x1000000004A01})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := CurrentStats()
|
||||
MaybeRegister(r)
|
||||
after := CurrentStats()
|
||||
if before.RegisteredRings != after.RegisteredRings {
|
||||
t.Fatalf("MaybeRegister mutated registry while disabled: %d -> %d",
|
||||
before.RegisteredRings, after.RegisteredRings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnregisterRing — unbinding restores the pre-register state.
|
||||
func TestUnregisterRing(t *testing.T) {
|
||||
t.Cleanup(DisableAccelerator)
|
||||
if err := UseAccelerator(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ring.NewRing(256, []uint64{0x1000000004A01})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := CurrentStats()
|
||||
if err := RegisterRing(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
UnregisterRing(r)
|
||||
after := CurrentStats()
|
||||
if before.RegisteredRings != after.RegisteredRings {
|
||||
t.Fatalf("registry not restored: %d -> %d", before.RegisteredRings, after.RegisteredRings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatsShape — sanity check that CurrentStats returns a populated
|
||||
// struct. Backend string is informational; we only assert it is set
|
||||
// (lattice/gpu always provides one even in the pure-Go build).
|
||||
func TestStatsShape(t *testing.T) {
|
||||
s := CurrentStats()
|
||||
if s.Backend == "" {
|
||||
t.Fatal("Backend is empty")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
cgpu "github.com/luxfi/corona/gpu"
|
||||
"github.com/luxfi/corona/hash"
|
||||
"github.com/luxfi/corona/sign"
|
||||
"github.com/luxfi/corona/utils"
|
||||
@@ -65,6 +66,7 @@ func NewCommitParams(suite hash.HashSuite) (*CommitParams, error) {
|
||||
return nil, err
|
||||
}
|
||||
rXi, _ := ring.NewRing(1<<sign.LogN, []uint64{sign.QXi})
|
||||
cgpu.MaybeRegister(r)
|
||||
|
||||
derive := func(tag []byte) structs.Matrix[ring.Poly] {
|
||||
seed := s.Hu(tag, sign.KeySize)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package threshold
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
cgpu "github.com/luxfi/corona/gpu"
|
||||
)
|
||||
|
||||
// BenchmarkPulsarSign measures the wall-clock cost of the 2-round
|
||||
// Pulsar threshold protocol's *online* phase (Round1 + Round2 +
|
||||
// Finalize, given a fresh GenerateKeys epoch). The IEEE S&P 2025
|
||||
// Pulsar evaluation calls out a 0.6 s online phase across 5
|
||||
// continents at the production shape; this bench gives the local
|
||||
// upper bound (network RTT is excluded; the cost here is pure CPU /
|
||||
// GPU compute).
|
||||
//
|
||||
// HONEST PERFORMANCE NOTE.
|
||||
//
|
||||
// At corona's production ring degree N=256 the single-poly Metal NTT
|
||||
// is slower than the pure-Go ring.SubRing.NTT (lattice/gpu's own
|
||||
// header documents this for every N up to 16384). The default
|
||||
// corona/gpu threshold = 1024 keeps single-poly dispatch OFF at
|
||||
// N=256, so BenchmarkPulsarSign_*_GPU here is effectively the same
|
||||
// kernel as the CPU bench with a small dispatcher branch cost; bench
|
||||
// noise dominates the comparison.
|
||||
//
|
||||
// The GPU win for Pulsar requires a BATCHED dispatch path that calls
|
||||
// lattice/gpu.MontgomeryNTTContext.Forward(data, batch>=4) at the
|
||||
// engine layer, bypassing the per-poly r.NTT() pinch point. That
|
||||
// kernel slot exists (see lattice/gpu/gpu_cgo.go::BatchNTT) but is
|
||||
// not yet plumbed through r.NTT — that's the v0.6+ NIST submission
|
||||
// pipeline work referenced in corona/dkg2/dkg2_gpu_accel.go.
|
||||
//
|
||||
// CPU vs GPU pairs (one bench function each) let `go test -bench .`
|
||||
// emit a side-by-side comparison without bench-fixture trickery; the
|
||||
// GPU bench remains useful as a regression watchdog (any change that
|
||||
// adds non-trivial dispatcher overhead will show up here).
|
||||
|
||||
func BenchmarkPulsarSign_2of3_CPU(b *testing.B) { benchPulsarSign(b, 3, 2, false) }
|
||||
func BenchmarkPulsarSign_2of3_GPU(b *testing.B) { benchPulsarSign(b, 3, 2, true) }
|
||||
func BenchmarkPulsarSign_5of7_CPU(b *testing.B) { benchPulsarSign(b, 7, 5, false) }
|
||||
func BenchmarkPulsarSign_5of7_GPU(b *testing.B) { benchPulsarSign(b, 7, 5, true) }
|
||||
func BenchmarkPulsarSign_7of11_CPU(b *testing.B) { benchPulsarSign(b, 11, 7, false) }
|
||||
func BenchmarkPulsarSign_7of11_GPU(b *testing.B) { benchPulsarSign(b, 11, 7, true) }
|
||||
|
||||
// 14-of-21 — production Lux consensus shape.
|
||||
func BenchmarkPulsarSign_14of21_CPU(b *testing.B) { benchPulsarSign(b, 21, 14, false) }
|
||||
func BenchmarkPulsarSign_14of21_GPU(b *testing.B) { benchPulsarSign(b, 21, 14, true) }
|
||||
|
||||
func benchPulsarSign(b *testing.B, n, thr int, gpuOn bool) {
|
||||
if gpuOn {
|
||||
if err := cgpu.UseAccelerator(); err != nil {
|
||||
b.Fatalf("UseAccelerator: %v", err)
|
||||
}
|
||||
} else {
|
||||
cgpu.DisableAccelerator()
|
||||
}
|
||||
b.Cleanup(cgpu.DisableAccelerator)
|
||||
|
||||
shares, _, err := GenerateKeys(thr, n, rand.Reader)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
signers := make([]*Signer, n)
|
||||
for i, sh := range shares {
|
||||
signers[i] = NewSigner(sh)
|
||||
}
|
||||
signerIDs := make([]int, n)
|
||||
for i := range signerIDs {
|
||||
signerIDs[i] = i
|
||||
}
|
||||
prfKey := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, prfKey); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
msg := "bench-pulsar-sign-online"
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sid := i + 1
|
||||
round1 := make(map[int]*Round1Data, n)
|
||||
for _, s := range signers {
|
||||
d := s.Round1(sid, prfKey, signerIDs)
|
||||
round1[d.PartyID] = d
|
||||
}
|
||||
round2 := make(map[int]*Round2Data, n)
|
||||
for _, s := range signers {
|
||||
d, err := s.Round2(sid, msg, prfKey, signerIDs, round1)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
round2[d.PartyID] = d
|
||||
}
|
||||
if _, err := signers[0].Finalize(round2); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"io"
|
||||
"math/big"
|
||||
|
||||
"github.com/luxfi/corona/gpu"
|
||||
"github.com/luxfi/corona/primitives"
|
||||
"github.com/luxfi/corona/sign"
|
||||
|
||||
@@ -42,6 +43,11 @@ type Params struct {
|
||||
}
|
||||
|
||||
// NewParams creates ring parameters.
|
||||
//
|
||||
// If corona/gpu has been opted into via UseAccelerator(), each created
|
||||
// ring is registered with the lattice/gpu per-SubRing dispatcher so
|
||||
// subsequent r.NTT / r.INTT calls inside the 2-round signing protocol
|
||||
// transparently route through the GPU. Output bytes are unchanged.
|
||||
func NewParams() (*Params, error) {
|
||||
r, err := ring.NewRing(1<<sign.LogN, []uint64{sign.Q})
|
||||
if err != nil {
|
||||
@@ -50,6 +56,9 @@ func NewParams() (*Params, error) {
|
||||
// QXi and QNu are powers of 2 for rounding, ignore ring errors
|
||||
rXi, _ := ring.NewRing(1<<sign.LogN, []uint64{sign.QXi})
|
||||
rNu, _ := ring.NewRing(1<<sign.LogN, []uint64{sign.QNu})
|
||||
// Best-effort GPU registration for the main Q ring. RXi / RNu are
|
||||
// power-of-two moduli so the NTT path is not taken on them.
|
||||
gpu.MaybeRegister(r)
|
||||
return &Params{R: r, RXi: rXi, RNu: rNu}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package threshold
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
cgpu "github.com/luxfi/corona/gpu"
|
||||
)
|
||||
|
||||
// deterministicReader is a SHA-256 keystream over a 32-byte seed,
|
||||
// suitable as an io.Reader for GenerateKeys. Two calls with the same
|
||||
// seed produce the same bytes, which fixes the trustedDealerKey across
|
||||
// the CPU and GPU legs of this test.
|
||||
type deterministicReader struct {
|
||||
seed [32]byte
|
||||
block [32]byte
|
||||
offset int
|
||||
ctr uint64
|
||||
}
|
||||
|
||||
func newDeterministicReader(seed [32]byte) *deterministicReader {
|
||||
r := &deterministicReader{seed: seed}
|
||||
r.refill()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *deterministicReader) refill() {
|
||||
var ctrBuf [8]byte
|
||||
binary.BigEndian.PutUint64(ctrBuf[:], r.ctr)
|
||||
h := sha256.New()
|
||||
h.Write(r.seed[:])
|
||||
h.Write(ctrBuf[:])
|
||||
sum := h.Sum(nil)
|
||||
copy(r.block[:], sum)
|
||||
r.offset = 0
|
||||
r.ctr++
|
||||
}
|
||||
|
||||
func (r *deterministicReader) Read(p []byte) (int, error) {
|
||||
n := 0
|
||||
for n < len(p) {
|
||||
if r.offset >= len(r.block) {
|
||||
r.refill()
|
||||
}
|
||||
m := copy(p[n:], r.block[r.offset:])
|
||||
r.offset += m
|
||||
n += m
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// TestThresholdSign_CPU_vs_GPU_ByteIdentical — running the full
|
||||
// 2-round Pulsar signing protocol with GPU NTT dispatch off and then
|
||||
// on (same deterministic dealer randomness, same message) MUST yield
|
||||
// byte-identical signature components. This is the core correctness
|
||||
// gate: a single off-by-one in the GPU NTT and the aggregator rejects
|
||||
// the share. The contract is defended by lattice/gpu (Montgomery
|
||||
// context byte-equal to ring.SubRing.NTT) and here we enforce it
|
||||
// end-to-end through corona's signing flow.
|
||||
func TestThresholdSign_CPU_vs_GPU_ByteIdentical(t *testing.T) {
|
||||
// Same dealer randomness on both legs.
|
||||
var seed [32]byte
|
||||
copy(seed[:], "byte-equal-gpu-vs-cpu-pulsar-rt!")
|
||||
|
||||
const k = 3
|
||||
const thr = 2
|
||||
sessionID := 7
|
||||
prfKey := []byte("byte-equal-prf-32-bytes-long-aaaa")
|
||||
message := "byte-equal-message-cpu-vs-gpu"
|
||||
signerIDs := []int{0, 1, 2}
|
||||
|
||||
runLeg := func(gpuOn bool) ([]byte, []byte, []byte) {
|
||||
t.Helper()
|
||||
if gpuOn {
|
||||
// Force-engage the GPU path even at N=256, where the
|
||||
// production threshold would leave dispatch off. The
|
||||
// goal here is correctness (byte-equal across paths),
|
||||
// not throughput.
|
||||
if err := cgpu.UseAcceleratorForce(); err != nil {
|
||||
t.Fatalf("UseAcceleratorForce: %v", err)
|
||||
}
|
||||
} else {
|
||||
cgpu.DisableAccelerator()
|
||||
}
|
||||
shares, groupKey, err := GenerateKeys(thr, k, newDeterministicReader(seed))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeys (gpu=%v): %v", gpuOn, err)
|
||||
}
|
||||
|
||||
signers := make([]*Signer, k)
|
||||
for i, sh := range shares {
|
||||
signers[i] = NewSigner(sh)
|
||||
}
|
||||
|
||||
round1 := make(map[int]*Round1Data)
|
||||
for _, s := range signers {
|
||||
d := s.Round1(sessionID, prfKey, signerIDs)
|
||||
round1[d.PartyID] = d
|
||||
}
|
||||
|
||||
round2 := make(map[int]*Round2Data)
|
||||
for _, s := range signers {
|
||||
d, err := s.Round2(sessionID, message, prfKey, signerIDs, round1)
|
||||
if err != nil {
|
||||
t.Fatalf("Round2 (gpu=%v): %v", gpuOn, err)
|
||||
}
|
||||
round2[d.PartyID] = d
|
||||
}
|
||||
|
||||
sig, err := signers[0].Finalize(round2)
|
||||
if err != nil {
|
||||
t.Fatalf("Finalize (gpu=%v): %v", gpuOn, err)
|
||||
}
|
||||
if !Verify(groupKey, message, sig) {
|
||||
t.Fatalf("self-verify failed (gpu=%v)", gpuOn)
|
||||
}
|
||||
|
||||
var cBuf, zBuf, dBuf bytes.Buffer
|
||||
if _, err := sig.C.WriteTo(&cBuf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sig.Z.WriteTo(&zBuf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sig.Delta.WriteTo(&dBuf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cBuf.Bytes(), zBuf.Bytes(), dBuf.Bytes()
|
||||
}
|
||||
|
||||
t.Cleanup(cgpu.DisableAccelerator)
|
||||
|
||||
cpuC, cpuZ, cpuD := runLeg(false)
|
||||
gpuC, gpuZ, gpuD := runLeg(true)
|
||||
|
||||
if !bytes.Equal(cpuC, gpuC) {
|
||||
t.Fatalf("sig.C differs: cpu=%x gpu=%x", head(cpuC), head(gpuC))
|
||||
}
|
||||
if !bytes.Equal(cpuZ, gpuZ) {
|
||||
t.Fatalf("sig.Z differs: cpu=%x gpu=%x", head(cpuZ), head(gpuZ))
|
||||
}
|
||||
if !bytes.Equal(cpuD, gpuD) {
|
||||
t.Fatalf("sig.Delta differs: cpu=%x gpu=%x", head(cpuD), head(gpuD))
|
||||
}
|
||||
}
|
||||
|
||||
func head(b []byte) []byte {
|
||||
if len(b) > 32 {
|
||||
return b[:32]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user