gpu: pulsar wires onto shared luxfi/lattice/v7 dispatcher

Corona = rank-1 Pulsar (R_q^k specialised at k=1). Both share the
lattice GPU NTT dispatcher; pulsar's gpu/ subpackage is a thin
RegisterRing wrapper matching corona/gpu/. Byte-equal contract
verified by TestPulsar_GPU_ByteEqual across (n,t) shapes (5,3),
(7,4), (10,7) and a UseAcceleratorForce variant pinning the
dispatcher at threshold=1.

Decomplecting note: pulsar's FIPS 204 NTT in mldsa_lattice.go
operates on [256]uint32 polynomials in 32-bit Montgomery form
(q=8380417, R=2^32); the lattice/v7 dispatcher operates on
[]uint64 in 64-bit Montgomery form. Pulsar's NTT does not route
through ring.SubRing today so the dispatcher is vacuous on
pulsar's N=256 production sign path. The gpu/ subpackage mirrors
corona/gpu's UseAccelerator / DisableAccelerator / RegisterRing /
Stats surface verbatim so multi-primitive consumers see one
opt-in. The byte-equal test is the regression guard for the v0.4
lift that widens mldsa_lattice.go to 64-bit Montgomery and routes
through ring.SubRing.

NIST-clean import discipline preserved. The reference package
github.com/luxfi/pulsar/ref/go/pkg/pulsar carries ZERO
non-test imports of lattice/v7 — go list -deps confirms only
circl + golang.org/x/crypto + stdlib transitively. The gpu/
subpackage is a separate import path; reviewers compiling the
reference for NIST submission with go build ./ref/go/pkg/pulsar
do not pull lattice or cgo. Lattice/v7 enters the import graph
only when go test runs (gpu surface tests + threshold_v03 byte-eq
test); lattice is pure-Go on default builds, no cgo engaged
unless -tags gpu && CGO_ENABLED=1 && Metal/CUDA link config.

Threshold combine routes through lattice/v7/gpu when a batched
dispatcher is engaged (UseAccelerator); single-poly dispatch
threshold is set ABOVE pulsar's production ring degree N=256
(defaultThreshold=1024, same default as corona) — operators with
a true batched dispatcher should lower SetThreshold via the gpu
subpackage's runtime knob.

Benches mirror corona/dkg2's CPU/GPU pair pattern: 5of7, 7of11,
14of21 at the v0.3 algebraic-threshold sign path. The
golang.org/x/crypto bump from v0.32.0 to v0.49.0 is a transitive
constraint from lattice/[email protected]; the bump is SHA3-byte-stable
(FIPS 202), verified by existing TestAlgebraic_ByteValid and KAT
round-trip passing post-bump.
This commit is contained in:
2026-05-31 12:21:38 -07:00
parent 7b54981697
commit cca73e1eaa
8 changed files with 821 additions and 7 deletions
+13 -2
View File
@@ -4,7 +4,18 @@ go 1.26.3
require (
github.com/cloudflare/circl v1.6.3
golang.org/x/crypto v0.32.0
github.com/luxfi/lattice/v7 v7.1.4
golang.org/x/crypto v0.49.0
)
require golang.org/x/sys v0.29.0 // indirect
require (
github.com/ALTree/bigfloat v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/luxfi/math v1.4.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/sys v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+31 -4
View File
@@ -1,6 +1,33 @@
github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM=
github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/luxfi/lattice/v7 v7.1.4 h1:hQR02M6cHTAV5+joOPi9gb9Gm+z/hKJnhJF4IlciIJs=
github.com/luxfi/lattice/v7 v7.1.4/go.mod h1:DmIQFi3mJiehVsR235l1NKYEU0JhU649OX5p7gMEW2c=
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+85
View File
@@ -0,0 +1,85 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package gpu_test
// bench_test.go — accelerator surface microbenchmarks.
//
// The v0.3 algebraic-threshold benches that exercise the
// CPU-vs-accelerator wall-clock comparison at production sizes
// (Pulsar-5of7, Pulsar-7of11) live in the pulsar package
// (ref/go/pkg/pulsar/threshold_v03_gpu_byte_eq_test.go's companion
// bench file would, but pulsar's bench_test.go already covers Sign
// timings — the per-mode signing cost is what matters operationally,
// since the accelerator flag has no semantic effect at N=256 on
// the dispatcher today).
//
// The microbenches here exercise:
// BenchmarkUseAccelerator — flag flip cost (atomic store)
// BenchmarkRegisterRing_N256 — per-ring SubRing registry add
// BenchmarkLatticeRingNTT_N256 — direct lattice/v7 SubRing.NTT
// call timing under the same
// defaultThreshold the accel
// wrapper installs (showing the
// single-poly cost the dispatcher
// would face if engaged).
//
// These are diagnostic; operators inspecting accelerator hot-loop
// budgets read these to confirm the wrapper overhead is dwarfed by
// the SubRing.NTT cost.
import (
"testing"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/pulsar/ref/go/pkg/pulsar/gpu"
)
func BenchmarkUseAccelerator(b *testing.B) {
b.Cleanup(gpu.DisableAccelerator)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = gpu.UseAccelerator()
}
}
func BenchmarkRegisterRing_N256(b *testing.B) {
b.Cleanup(gpu.DisableAccelerator)
if err := gpu.UseAccelerator(); err != nil {
b.Fatal(err)
}
r, err := ring.NewRing(256, []uint64{0x1000000004A01})
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := gpu.RegisterRing(r); err != nil {
b.Skipf("GPU registration unavailable on this build: %v", err)
}
}
}
// BenchmarkLatticeRingNTT_N256 measures the cost of one ring.SubRing
// forward NTT at pulsar's production ring degree N=256 on the q used
// by the surface tests. This is the throughput the lattice dispatcher
// would have to beat for single-poly dispatch to be a net win at
// N=256 — and the reason gpu.defaultThreshold is set to 1024 instead
// of 256 on the wrapper.
func BenchmarkLatticeRingNTT_N256(b *testing.B) {
r, err := ring.NewRing(256, []uint64{0x1000000004A01})
if err != nil {
b.Fatal(err)
}
s := r.SubRings[0]
src := make([]uint64, 256)
for i := range src {
src[i] = uint64(i)
}
dst := make([]uint64, 256)
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.NTT(src, dst)
}
}
+264
View File
@@ -0,0 +1,264 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package gpu wires Pulsar (FIPS 204 M-LWE threshold signing) onto the
// shared luxfi/lattice/v7 GPU NTT dispatcher. Pulsar and luxfi/corona
// (R-LWE threshold signing) share this dispatcher by construction:
// Corona is rank-1 Pulsar (the module shape R_q^k specialised at k=1),
// so the polynomial arithmetic — NTT, sampling, multiplication — is
// the same primitive. Sharing the dispatcher means one and only one
// place owns the cgo plumbing: lattice/v7/gpu. Corona's
// corona/gpu/gpu.go is the structural template for this package;
// every export here has the matching corona counterpart.
//
// IMPORT-PATH DISCIPLINE — NIST-CLEAN REFERENCE
//
// The pulsar NIST reference implementation lives at the parent import
// path github.com/luxfi/pulsar/ref/go/pkg/pulsar. That package's
// import graph carries ZERO dependency on luxfi/lattice/v7 — its
// only external imports are encoding/binary, crypto/rand,
// crypto/elliptic, golang.org/x/crypto/sha3, and the standard library.
// Reviewers compiling the reference do not pull lattice, cgo, or any
// GPU plumbing.
//
// This subpackage is a SEPARATE import path —
// github.com/luxfi/pulsar/ref/go/pkg/pulsar/gpu — that consumers
// reach for explicitly when they want to opt into the shared
// dispatcher. Compiling the reference (go test ./ref/go/pkg/pulsar/)
// does not transitively pull this subpackage.
//
// DECOMPLECTING NOTE
//
// Pulsar's per-coefficient NTT (FIPS 204 §3.6) operates on
// [256]uint32 polynomials in 32-bit Montgomery form with q=8380417
// and R=2^32. The lattice/v7 ring.SubRing.NTT operates on []uint64
// polynomials in 64-bit Montgomery form. The byte format and the
// Montgomery constants differ, so pulsar's reference NTT in
// mldsa_lattice.go is NOT byte-equal to ring.SubRing.NTT for the
// same FIPS 204 input. This is by design — pulsar's NTT bytes are
// pinned by the FIPS 204 KAT contract.
//
// The lattice/v7/gpu dispatcher operates at the ring.SubRing layer:
// it is engaged when a ring.SubRing is registered with
// gpu.RegisterSubRing and the SubRing's NTT call lands above the
// gpu.NTTThreshold. Pulsar's per-poly FIPS 204 NTT does not flow
// through ring.SubRing, so the dispatcher is not engaged by pulsar's
// production sign path today.
//
// Why ship this surface anyway:
//
// 1. ONE WAY TO DO EVERYTHING. Corona and pulsar consumers see the
// same UseAccelerator / DisableAccelerator / RegisterRing /
// Stats surface. Multi-primitive harnesses (e.g. a research
// environment exercising both corona R-LWE and pulsar M-LWE
// against a shared lattice/v7 ring) opt in with one call.
//
// 2. BYTE-EQUALITY BY CONSTRUCTION. TestPulsar_GPU_ByteEqual asserts
// that signatures emitted with UseAccelerator() enabled are
// bit-identical to signatures emitted with the accelerator
// disabled. Today this holds vacuously (the dispatcher is not
// engaged) but the test is the load-bearing contract: when the
// v0.4 lift widens pulsar's NTT to a 64-bit Montgomery form
// routable through ring.SubRing, the test must continue to pass.
//
// 3. STATS + DIAGNOSTICS. CurrentStats() reports the active backend
// (Metal / CUDA / CPU) so operators can confirm the lattice/v7
// cgo plumbing is wired correctly without grepping the lattice
// source.
//
// THRESHOLD GATING
//
// Pulsar's production ring degree is N=256 (FIPS 204 fixed). Single-
// poly Metal NTT on Apple M1 Max is strictly slower than the pure-Go
// path for every N up to 16384 (lattice/v7/gpu/gpu_montgomery_cgo.go).
// The GPU dispatch win lives in BATCHED dispatch — many polynomials
// submitted in one kernel launch. Engaging single-poly GPU dispatch
// at N=256 regresses wall-clock by roughly 4x.
//
// Therefore UseAccelerator() picks defaultThreshold=1024, ABOVE
// Pulsar's production ring degree. Operators with a batched
// dispatcher available (future luxfi/accel batch NTT plumbed through
// ring.NTT, or a v0.4 batched mldsa_lattice.go path) can lower the
// threshold via SetThreshold; correctness tests can call
// UseAcceleratorForce() to set threshold=1 and exercise the GPU path
// at N=256 as a byte-equality gate.
//
// BUILD-TAG DISCIPLINE
//
// This package has NO build tags. It compiles identically across
// cgo+gpu builds and pure-Go builds because the cgo plumbing lives
// entirely in lattice/v7/gpu (gpu_montgomery_cgo.go vs
// gpu_montgomery_purego.go). UseAccelerator() flips an atomic flag;
// SetNTTThreshold writes to lattice's threshold; on a pure-Go build
// lattice's dispatcher returns false and the canonical pure-Go
// SubRing.NTT path runs. Output bytes are unchanged.
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: pulsar'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. Consumers invoke
// UseAccelerator() once at startup; subsequent RegisterRing calls
// from any caller (pulsar's future v0.4 batched NTT lift, a research
// harness, or a cross-primitive multi-ring runtime) consult this via
// Enabled().
var accelEnabled atomic.Bool
// UseAccelerator opts every subsequent caller into the lattice/v7
// GPU NTT dispatch path. Idempotent. Safe to call from package init
// or from a runtime configuration step before any pulsar signer is
// constructed.
//
// On a pure-Go (no cgo) build, the flag still flips and the lattice
// threshold is set, but the lattice dispatcher's Available() returns
// false so the canonical pure-Go SubRing.NTT 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 pulsar'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 small
// rings; production callers should use UseAccelerator() instead.
func UseAcceleratorForce() error {
accelEnabled.Store(true)
gpu.SetNTTThreshold(1)
return nil
}
// DisableAccelerator clears the opt-in. Subsequent RegisterRing /
// MaybeRegister calls become no-ops. 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 callers
// consult this to decide whether to call RegisterRing.
func Enabled() bool { return accelEnabled.Load() }
// SetThreshold overrides the lattice/v7/gpu single-poly dispatch
// threshold. Pass 0 to disable single-poly GPU dispatch entirely.
// See lattice/v7/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 + lattice 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/v7/gpu.GetBackend(); re-exported here so
// callers do not import lattice/v7/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/v7/gpu
// per-SubRing registry. Idempotent per SubRing pointer; safe to call
// multiple times with the same ring.
//
// Callers exercising a luxfi/lattice/v7 ring (research harnesses,
// multi-primitive runtimes sharing the dispatcher across pulsar +
// corona, the v0.4 batched mldsa_lattice.go lift) register their
// rings here. The pulsar reference implementation does NOT register
// any ring today — its FIPS 204 NTT is in-package and uint32-Montgomery
// so it does not route through ring.SubRing.
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 consumer code invokes from
// its NewParams() / ring-construction site. If Enabled() is true it
// binds the ring; otherwise no-op.
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,
}
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package gpu_test
// gpu_test.go — surface tests for the pulsar gpu subpackage.
//
// Mirrors corona/gpu/gpu_test.go: idempotency of UseAccelerator, the
// flag-clear semantics of DisableAccelerator, RegisterRing /
// UnregisterRing round-trip on a synthetic lattice/v7 ring, the
// MaybeRegister no-op-when-disabled behaviour, and stats shape.
//
// The byte-equality contract — TestPulsar_GPU_ByteEqual — lives in the
// pulsar package (ref/go/pkg/pulsar/threshold_v03_gpu_byte_eq_test.go)
// because driving a full v0.3 algebraic threshold ceremony requires
// the package-private polyVec / unpackPolyVec helpers that
// Round2Sign's peer-W map consumes. The pulsar package's TEST import
// graph is allowed to reference gpu (and thus lattice/v7) — the
// non-test reference compile (go build ./ref/go/pkg/pulsar) does NOT
// pull lattice/v7.
import (
"testing"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/pulsar/ref/go/pkg/pulsar/gpu"
)
// TestUseAcceleratorIdempotent — opting in twice is harmless and leaves
// the flag set.
func TestUseAcceleratorIdempotent(t *testing.T) {
t.Cleanup(gpu.DisableAccelerator)
if err := gpu.UseAccelerator(); err != nil {
t.Fatalf("UseAccelerator first call: %v", err)
}
if !gpu.Enabled() {
t.Fatal("Enabled() false after first UseAccelerator")
}
if err := gpu.UseAccelerator(); err != nil {
t.Fatalf("UseAccelerator second call: %v", err)
}
if !gpu.Enabled() {
t.Fatal("Enabled() false after second UseAccelerator")
}
}
// TestDisableAcceleratorClearsFlag — DisableAccelerator returns the
// global to its baseline state.
func TestDisableAcceleratorClearsFlag(t *testing.T) {
t.Cleanup(gpu.DisableAccelerator)
if err := gpu.UseAccelerator(); err != nil {
t.Fatal(err)
}
gpu.DisableAccelerator()
if gpu.Enabled() {
t.Fatal("Enabled() true after DisableAccelerator")
}
}
// TestRegisterRingIdempotent — calling RegisterRing twice with the same
// ring does not double-register SubRings.
func TestRegisterRingIdempotent(t *testing.T) {
t.Cleanup(gpu.DisableAccelerator)
if err := gpu.UseAccelerator(); err != nil {
t.Fatal(err)
}
r, err := ring.NewRing(256, []uint64{0x1000000004A01})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { gpu.UnregisterRing(r) })
if err := gpu.RegisterRing(r); err != nil {
// On non-GPU builds (no cgo / no Metal / no CUDA) lattice/v7/gpu
// RegisterSubRing returns "GPU unavailable". The rest of the
// surface is exercised by the other tests; skip this idempotency
// check rather than failing.
t.Skipf("GPU registration unavailable on this build: %v", err)
}
beforeStats := gpu.CurrentStats()
if err := gpu.RegisterRing(r); err != nil {
t.Fatalf("RegisterRing second: %v", err)
}
afterStats := gpu.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(gpu.DisableAccelerator)
gpu.DisableAccelerator()
r, err := ring.NewRing(256, []uint64{0x1000000004A01})
if err != nil {
t.Fatal(err)
}
before := gpu.CurrentStats()
gpu.MaybeRegister(r)
after := gpu.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(gpu.DisableAccelerator)
if err := gpu.UseAccelerator(); err != nil {
t.Fatal(err)
}
r, err := ring.NewRing(256, []uint64{0x1000000004A01})
if err != nil {
t.Fatal(err)
}
before := gpu.CurrentStats()
if err := gpu.RegisterRing(r); err != nil {
t.Skipf("GPU registration unavailable on this build: %v", err)
}
gpu.UnregisterRing(r)
after := gpu.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/v7/gpu always provides one even in the pure-Go build).
func TestStatsShape(t *testing.T) {
s := gpu.CurrentStats()
if s.Backend == "" {
t.Fatal("Backend is empty")
}
}
// TestSetThreshold — direct write to lattice/v7/gpu threshold via the
// wrapper. Surface-only; no semantic side effect beyond stats round-trip.
func TestSetThreshold(t *testing.T) {
t.Cleanup(func() {
gpu.SetThreshold(0)
})
gpu.SetThreshold(2048)
if got := gpu.CurrentStats().Threshold; got != 2048 {
t.Fatalf("Threshold not honored: got %d want 2048", got)
}
gpu.SetThreshold(0)
if got := gpu.CurrentStats().Threshold; got != 0 {
t.Fatalf("Threshold not cleared: got %d want 0", got)
}
}
// TestUseAcceleratorForce — the threshold=1 mode dispatches every
// registered SubRing NTT to the GPU. Used by the byte-equal test in
// the pulsar package to exercise the dispatcher at small N.
func TestUseAcceleratorForce(t *testing.T) {
t.Cleanup(gpu.DisableAccelerator)
if err := gpu.UseAcceleratorForce(); err != nil {
t.Fatalf("UseAcceleratorForce: %v", err)
}
if !gpu.Enabled() {
t.Fatal("Enabled() false after UseAcceleratorForce")
}
if got := gpu.CurrentStats().Threshold; got != 1 {
t.Fatalf("forced threshold not 1: got %d", got)
}
}
@@ -0,0 +1,104 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// threshold_v03_gpu_bench_test.go — CPU vs accelerator wall-clock
// benchmarks for the v0.3 algebraic-threshold sign path at small
// quorum shapes. Mirrors the corona/dkg2 bench discipline at
// corona/dkg2/dkg2_gpu_test.go (BenchmarkDKG2_GPU_Round1_{5of7,
// 7of11, 14of21}_{CPU,GPU}).
//
// Today the dispatcher is vacuous on pulsar's N=256 single-poly NTT
// (gpu.defaultThreshold=1024 > 256) so the CPU and GPU legs report
// equivalent ns/op modulo measurement noise. The benches exist as
// the wall-clock regression guard for the v0.4 lift that widens
// mldsa_lattice.go's NTT to 64-bit Montgomery and routes through
// ring.SubRing — at that point the GPU leg measures the BATCHED
// dispatch path and should win at production sizes.
import (
"testing"
pgpu "github.com/luxfi/pulsar/ref/go/pkg/pulsar/gpu"
)
// 5-of-7 — the headline shape the task asks for (matches corona's
// equivalent dkg2 bench).
func BenchmarkPulsarSign_5of7_CPU(b *testing.B) {
pgpu.DisableAccelerator()
benchV03Ceremony(b, 7, 5)
}
func BenchmarkPulsarSign_5of7_GPU(b *testing.B) {
if err := pgpu.UseAccelerator(); err != nil {
b.Fatal(err)
}
b.Cleanup(pgpu.DisableAccelerator)
benchV03Ceremony(b, 7, 5)
}
// 7-of-11 — intermediate.
func BenchmarkPulsarSign_7of11_CPU(b *testing.B) {
pgpu.DisableAccelerator()
benchV03Ceremony(b, 11, 7)
}
func BenchmarkPulsarSign_7of11_GPU(b *testing.B) {
if err := pgpu.UseAccelerator(); err != nil {
b.Fatal(err)
}
b.Cleanup(pgpu.DisableAccelerator)
benchV03Ceremony(b, 11, 7)
}
// 14-of-21 — production Lux consensus committee shape, matches
// BenchmarkDKG2_GPU_Round1_14of21 in corona.
func BenchmarkPulsarSign_14of21_CPU(b *testing.B) {
pgpu.DisableAccelerator()
benchV03Ceremony(b, 21, 14)
}
func BenchmarkPulsarSign_14of21_GPU(b *testing.B) {
if err := pgpu.UseAccelerator(); err != nil {
b.Fatal(err)
}
b.Cleanup(pgpu.DisableAccelerator)
benchV03Ceremony(b, 21, 14)
}
// benchV03Ceremony drives the v0.3 ceremony to one successful
// signature per iteration. The rejection-restart loop is part of the
// measurement because operationally a higher restart probability
// pulls the GPU-leg wall-clock down proportionally with the per-
// attempt NTT cost — that is what we want the bench to reflect.
func benchV03Ceremony(b *testing.B, n, threshold int) {
b.Helper()
msg := []byte("pulsar v03 bench")
var sid [16]byte
copy(sid[:], "v03-bench-001-aa")
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Vary sid per iteration so the rejection-restart RNG path
// doesn't latch onto a single deterministic acceptance.
sid[8] = byte(i)
sid[9] = byte(i >> 8)
var (
sig *Signature
err error
)
for attempt := uint32(0); attempt < 64; attempt++ {
sig, _, _, _, _, _, _, err = stageAlgebraic(b, n, threshold, msg, sid, attempt)
if err == nil {
break
}
if err != ErrAlgebraicRestart {
b.Fatalf("attempt %d unexpected err: %v", attempt, err)
}
}
if sig == nil {
b.Fatalf("no acceptance within 64 attempts")
}
}
}
@@ -0,0 +1,152 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// threshold_v03_gpu_byte_eq_test.go — byte-equality contract between
// the pure-Go v0.3 algebraic-threshold ML-DSA path and the path with
// gpu.UseAccelerator() opted in.
//
// The lattice/v7/gpu dispatcher is the shared GPU NTT entry point used
// by luxfi/corona's R-LWE threshold signing and (by construction of
// this subpackage) luxfi/pulsar's M-LWE threshold signing. Corona is
// rank-1 Pulsar (R_q^k specialised at k=1); the polynomial arithmetic
// is the same primitive. Sharing the dispatcher keeps one and only
// one place — luxfi/lattice/v7/gpu — owning the cgo plumbing.
//
// TODAY (v0.3): pulsar's FIPS 204 NTT in mldsa_lattice.go operates on
// [256]uint32 polynomials in 32-bit Montgomery form. The lattice/v7
// dispatcher operates on []uint64 polynomials in 64-bit Montgomery
// form. Pulsar's per-poly NTT does NOT route through ring.SubRing, so
// the dispatcher is not engaged by pulsar's production sign path at
// pulsar's N=256 production ring degree. The byte-equality contract
// therefore reduces to "both legs run the canonical pure-Go FIPS 204
// NTT" — vacuously equal.
//
// LOAD-BEARING SCOPE: this test is the regression guard for the v0.4
// lift that widens mldsa_lattice.go's NTT to a 64-bit Montgomery form
// routable through ring.SubRing. At that point the lattice
// dispatcher's per-SubRing fast-path WILL engage on pulsar
// signatures, and any byte divergence between the dispatched leg and
// the pure-Go leg surfaces here as a test failure before it slips
// into a KAT regression.
//
// IMPORT-GRAPH NOTE: this test file imports
// github.com/luxfi/pulsar/ref/go/pkg/pulsar/gpu (the wrapper around
// lattice/v7/gpu). The import lives in a _test.go file so the
// non-test reference compile of github.com/luxfi/pulsar/ref/go/pkg/pulsar
// does NOT pull lattice/v7. Reviewers building the reference for
// NIST submission with `go build ./ref/go/pkg/pulsar` see only the
// circl + golang.org/x/crypto + stdlib transitive graph. Reviewers
// running `go test ./ref/go/pkg/pulsar` pull lattice/v7 as a pure-Go
// dependency; no cgo is engaged unless `-tags gpu` AND `CGO_ENABLED=1`
// AND a Metal / CUDA backend link config are all set.
import (
"bytes"
"testing"
pgpu "github.com/luxfi/pulsar/ref/go/pkg/pulsar/gpu"
)
// TestPulsar_GPU_ByteEqual asserts that the v0.3 algebraic threshold
// ML-DSA signature produced via the pure-Go path is bit-identical to
// the signature produced via the GPU-accelerator-enabled path for the
// same committee, threshold, seed, message, and session id.
//
// Exercised shapes mirror the existing TestAlgebraic_FullCycle_*
// coverage: (5, 3) is the canonical small fixture; (7, 4) and (10, 7)
// add intermediate quorum sizes so a divergence in any code path
// proportional to t, n, or t·N would surface.
func TestPulsar_GPU_ByteEqual(t *testing.T) {
for _, tc := range []struct {
name string
n, t int
}{
{"n5_t3", 5, 3},
{"n7_t4", 7, 4},
{"n10_t7", 10, 7},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
msg := []byte("pulsar gpu byte-eq " + tc.name)
var sid [16]byte
copy(sid[:], "v03-gpu-byte-eq!")
runLeg := func(gpuOn bool) []byte {
if gpuOn {
if err := pgpu.UseAccelerator(); err != nil {
t.Fatalf("UseAccelerator: %v", err)
}
} else {
pgpu.DisableAccelerator()
}
sig := runV03Once(t, tc.n, tc.t, msg, sid)
return sig.Bytes
}
cpu := runLeg(false)
gpu := runLeg(true)
pgpu.DisableAccelerator() // clean global state for subsequent tests
if !bytes.Equal(cpu, gpu) {
t.Fatalf("signature bytes mismatch: cpu_len=%d gpu_len=%d cpu_head=%x gpu_head=%x",
len(cpu), len(gpu), cpu[:32], gpu[:32])
}
})
}
}
// TestPulsar_GPU_ByteEqual_Force exercises the byte-equal contract
// with the lattice/v7 single-poly dispatch threshold forced to 1. On
// a cgo+gpu build with a Metal / CUDA backend live this would
// dispatch every registered SubRing NTT to the GPU regardless of N —
// but pulsar's FIPS 204 NTT does not flow through ring.SubRing today
// so the contract still reduces to the pure-Go path. The test
// exists so a future v0.4 batched lift that DOES route through
// ring.SubRing is automatically gated on byte equality at the
// smallest possible dispatch threshold.
func TestPulsar_GPU_ByteEqual_Force(t *testing.T) {
msg := []byte("pulsar gpu byte-eq forced")
var sid [16]byte
copy(sid[:], "v03-gpu-be-force")
pgpu.DisableAccelerator()
cpu := runV03Once(t, 5, 3, msg, sid).Bytes
if err := pgpu.UseAcceleratorForce(); err != nil {
t.Fatalf("UseAcceleratorForce: %v", err)
}
forced := runV03Once(t, 5, 3, msg, sid).Bytes
pgpu.DisableAccelerator()
if !bytes.Equal(cpu, forced) {
t.Fatalf("forced GPU signature differs: cpu_head=%x forced_head=%x",
cpu[:32], forced[:32])
}
}
// runV03Once drives the v0.3 ceremony retry loop and returns the
// emitted signature. It reuses the existing stageAlgebraic helper
// from threshold_v03_test.go — that helper handles identity setup,
// session-key derivation, the rejection-restart loop, and aggregator
// call. Reusing it keeps THIS file focused on the byte-equality
// assertion, not on re-deriving the ceremony fixture.
func runV03Once(t testing.TB, n, threshold int, msg []byte, sid [16]byte) *Signature {
t.Helper()
var (
sig *Signature
err error
)
for attempt := uint32(0); attempt < 64; attempt++ {
sig, _, _, _, _, _, _, err = stageAlgebraic(t, n, threshold, msg, sid, attempt)
if err == nil {
return sig
}
if err != ErrAlgebraicRestart {
t.Fatalf("attempt %d unexpected err: %v", attempt, err)
}
}
t.Fatalf("no acceptance within 64 attempts")
return nil
}
+1 -1
View File
@@ -33,7 +33,7 @@ import (
// stageAlgebraic runs a deterministic v0.3 algebraic-threshold ceremony
// to produce a signature, returning the signature, group public key,
// and the trusted-dealer setup. Used by every test below.
func stageAlgebraic(t *testing.T, n, threshold int, msg []byte, sid [16]byte, attempt uint32) (
func stageAlgebraic(t testing.TB, n, threshold int, msg []byte, sid [16]byte, attempt uint32) (
*Signature,
*PublicKey,
*AlgebraicSetup,