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:
Hanzo AI
2026-05-21 13:41:15 -07:00
parent 7ec104b20e
commit 2e22541859
13 changed files with 1233 additions and 25 deletions
+5
View File
@@ -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
}
+40
View File
@@ -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
View File
@@ -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,
+300
View File
@@ -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
}
+13
View File
@@ -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
}
+11
View File
@@ -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
}
+231
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
}
+2
View File
@@ -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)
+103
View File
@@ -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)
}
}
}
+9
View File
@@ -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
}
+158
View File
@@ -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
}