Files
corona/dkg2/dkg2_gpu.go
T
Hanzo AI 2e22541859 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
2026-05-21 13:41:15 -07:00

301 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}