backend: add GPU substrate selector with fallback policy + ABI guards

Adds the canonical runtime substrate selector for luxfi/crypto and decomplects
all per-algorithm GPU dispatchers behind it.

New `backend` API:
- Default()/SetDefault/Resolved()/IsGPU/IsCGo/IsVanilla — runtime selection
- CGoAvailable()/GPUAvailable() — real probes (was stubbed)
- Probe() returning Snapshot{Default, Resolved, CGo, GPU, Disabled,
  GPUBackend, GPUDeviceCount, AccelVersion, Fallbacks}
- GPUDisabled() reads LUX_GPU_DISABLE operator kill switch
- RecordFallback(reason, where) atomic counter + one-shot log per reason,
  low-cardinality FallbackReason enum (disabled / unsupported / probe_failed
  / backend_unavailable / abi_mismatch)

Dispatcher cleanup (one-and-one-way):
- All Resolve(gpuhost.Available(), false) call sites replaced with IsGPU()
- hqc switched to IsVanilla() (its accel batch wins for any non-vanilla pick)
- gpu/gpu.go now delegates entirely to backend (no separate session)
- internal/gpuhost dropped Snapshot()/Provenance — backend.Probe() canonical

Build tag policy: CGo is the only gate. There is no `gpu` build tag.
LLM.md documents the canonical surface.
This commit is contained in:
Hanzo AI
2026-05-24 14:16:55 -07:00
parent 5a8c39d450
commit ddff44bbee
29 changed files with 683 additions and 140 deletions
+42 -8
View File
@@ -1664,18 +1664,19 @@ gpuCalls, cpuCalls := ctx.Stats()
### Build Tags
| Build | Tags | GPU Support |
|-------|------|-------------|
| Default | (none) | CPU only (gnark-crypto) |
| Metal | `darwin,arm64,cgo,gpu` | Apple Silicon GPU |
| CUDA | `linux,cgo,gpu` | NVIDIA GPU (future) |
CGO is the only gate. There is no `gpu` build tag.
| Build | GPU Support |
|-------|-------------|
| `CGO_ENABLED=0` | Pure Go fallback (gnark-crypto). |
| `CGO_ENABLED=1` | Runtime probe via `luxfi/accel`: Metal on darwin, CUDA on linux/NVIDIA, CPU oracle otherwise. |
```bash
# CPU-only build
go build ./...
CGO_ENABLED=0 go build ./...
# Metal GPU build
CGO_ENABLED=1 go build -tags "gpu" ./...
# GPU-capable build (runtime decides)
go build ./...
```
### Files
@@ -1721,4 +1722,37 @@ CGO_ENABLED=1 go test ./gpu/...
---
## Backend Selector
One-and-one-way runtime substrate selection in `github.com/luxfi/crypto/backend`:
| Surface | Purpose |
|---------|---------|
| `Default() / SetDefault(b)` | Active selection (Auto/Vanilla/CGo/GPU). Env `CRYPTO_BACKEND` overrides at init. |
| `CGoAvailable()` | Compile-time constant: true iff built with `CGO_ENABLED=1`. |
| `GPUAvailable()` | Lazy probe via `internal/gpuhost``luxfi/accel.Init()`. |
| `Available(b)` | Per-backend availability. |
| `Resolved()` | `Resolve(GPUAvailable(), CGoAvailable())` — one-call shortcut. |
| `IsGPU() / IsCGo() / IsVanilla()` | Dispatcher gates. |
| `Probe() → Snapshot` | Diagnostic line: `backend{default=auto resolved=cgo cgo=true gpu=false accel=...}`. |
### Dispatcher pattern
```go
func batchGPU(...) (bool, error) {
if !backend.IsGPU() { return false, nil }
sess := gpuhost.Session()
// pack tensors, call sess.Crypto().Keccak256(...), copy out
}
```
Wired in: `keccak256 sha256 blake3 bls ed25519 secp256k1 mldsa mlkem slhdsa hqc`.
`hqc` uses `!backend.IsVanilla()` (accel batch beats per-item PQClean for any
non-Vanilla selection).
### `gpu/` package
Public diagnostic surface — delegates entirely to `backend`. `Available()`,
`Backend()`, `Devices()`, `Version()` are thin wrappers. No separate session.
**Single source of truth for AI assistants on this project.**
+52 -12
View File
@@ -94,30 +94,70 @@ func SetDefault(b Backend) {
atomic.StoreUint32(&current, uint32(b))
}
// Available is reserved for backend probing. Today, Auto and Vanilla are
// always available; CGo and GPU are reported by build-tagged shims and the
// runtime resolver.
// CGoAvailable reports whether the binary was compiled with CGO_ENABLED=1.
// The answer is a compile-time constant: cgoLinked is set by cgo_yes.go
// when cgo is on and cgo_no.go when cgo is off.
func CGoAvailable() bool { return cgoLinked }
// GPUAvailable reports whether the luxfi/accel GPU substrate is reachable
// in this process. First call lazily initialises accel via gpuhost; the
// answer is cached afterwards. Returns false unconditionally when the
// LUX_GPU_DISABLE kill switch is set (see disable.go).
func GPUAvailable() bool {
if gpuDisabled {
return false
}
return gpuLinked()
}
// Available reports whether backend b is currently usable. Auto and Vanilla
// are always available; CGo requires CGO_ENABLED=1; GPU requires a live
// accel session with at least one device.
func Available(b Backend) bool {
switch b {
case Auto, Vanilla:
return true
case CGo:
return CGoAvailable()
case GPU:
return GPUAvailable()
default:
return false
}
}
// Resolved picks the concrete backend for the caller using the real
// CGo / GPU probes. This is the one-call shortcut for the common pattern:
//
// if backend.Resolved() == backend.GPU {
// // dispatch GPU path
// }
//
// Equivalent to Resolve(GPUAvailable(), CGoAvailable()).
func Resolved() Backend { return Resolve(GPUAvailable(), CGoAvailable()) }
// IsGPU reports whether Resolved() picks GPU. Algorithm dispatchers gate
// their GPU path with this:
//
// if !backend.IsGPU() { return false, nil }
func IsGPU() bool { return Resolved() == GPU }
// IsCGo reports whether Resolved() picks CGo.
func IsCGo() bool { return Resolved() == CGo }
// IsVanilla reports whether Resolved() picks Vanilla. Useful for dispatchers
// whose accelerated path covers both GPU and CGo and only needs to bail out
// on explicit Vanilla selection (e.g. crypto/hqc batch entrypoints).
func IsVanilla() bool { return Resolved() == Vanilla }
// Resolve picks a concrete backend for the caller. If Default() is Auto the
// resolution falls back through GPU → CGo → Vanilla, choosing the first
// backend reported as available by the supplied probes. probes may be nil;
// in that case Resolve returns Vanilla for Auto.
// backend reported as available by the supplied probes.
//
// This is the primary entry point used inside algorithm packages:
//
// switch backend.Resolve(gpuOK, cgoOK) {
// case backend.GPU: return keccak256GPU(in)
// case backend.CGo: return keccak256CGo(in)
// default: return keccak256Vanilla(in)
// }
// Prefer Resolved() / IsGPU() / IsCGo() / IsVanilla() — they call this
// function with the real probes from CGoAvailable() and GPUAvailable().
// The four-arg form remains exported for callers that already know the
// answer (tests, custom dispatchers with their own probes).
func Resolve(gpuAvailable, cgoAvailable bool) Backend {
switch d := Default(); d {
case Vanilla:
+86
View File
@@ -85,6 +85,92 @@ func TestStringRoundTrip(t *testing.T) {
}
}
func TestCGoAvailableMatchesBuildTag(t *testing.T) {
// cgoLinked is a compile-time constant; CGoAvailable is its accessor.
// On a cgo build the value is true, on a no-cgo build it is false.
// Either way, repeated calls must return the same value.
v1 := CGoAvailable()
v2 := CGoAvailable()
if v1 != v2 {
t.Fatalf("CGoAvailable() not stable: %v then %v", v1, v2)
}
}
func TestGPUAvailableStable(t *testing.T) {
// GPUAvailable goes through gpuhost.Available which caches the
// result of a single accel.Init. Two consecutive calls must agree.
v1 := GPUAvailable()
v2 := GPUAvailable()
if v1 != v2 {
t.Fatalf("GPUAvailable() not stable: %v then %v", v1, v2)
}
}
func TestAvailableMatchesProbes(t *testing.T) {
if !Available(Auto) || !Available(Vanilla) {
t.Fatal("Auto and Vanilla must always be available")
}
if Available(CGo) != CGoAvailable() {
t.Errorf("Available(CGo)=%v differs from CGoAvailable()=%v",
Available(CGo), CGoAvailable())
}
if Available(GPU) != GPUAvailable() {
t.Errorf("Available(GPU)=%v differs from GPUAvailable()=%v",
Available(GPU), GPUAvailable())
}
}
func TestResolvedMatchesResolve(t *testing.T) {
t.Cleanup(func() { SetDefault(Auto) })
for _, d := range []Backend{Auto, Vanilla, CGo, GPU} {
SetDefault(d)
want := Resolve(GPUAvailable(), CGoAvailable())
got := Resolved()
if got != want {
t.Errorf("Resolved() with Default=%s = %s; want %s", d, got, want)
}
}
}
func TestIsHelpersMatchResolved(t *testing.T) {
t.Cleanup(func() { SetDefault(Auto) })
for _, d := range []Backend{Auto, Vanilla, CGo, GPU} {
SetDefault(d)
r := Resolved()
if IsGPU() != (r == GPU) {
t.Errorf("IsGPU mismatch under default=%s: IsGPU=%v Resolved=%s", d, IsGPU(), r)
}
if IsCGo() != (r == CGo) {
t.Errorf("IsCGo mismatch under default=%s: IsCGo=%v Resolved=%s", d, IsCGo(), r)
}
if IsVanilla() != (r == Vanilla) {
t.Errorf("IsVanilla mismatch under default=%s: IsVanilla=%v Resolved=%s", d, IsVanilla(), r)
}
}
}
func TestProbeIsConsistentWithHelpers(t *testing.T) {
t.Cleanup(func() { SetDefault(Auto) })
SetDefault(Vanilla)
p := Probe()
if p.Default != Default() {
t.Errorf("Probe.Default=%s != Default()=%s", p.Default, Default())
}
if p.Resolved != Resolved() {
t.Errorf("Probe.Resolved=%s != Resolved()=%s", p.Resolved, Resolved())
}
if p.CGo != CGoAvailable() {
t.Errorf("Probe.CGo=%v != CGoAvailable()=%v", p.CGo, CGoAvailable())
}
if p.GPU != GPUAvailable() {
t.Errorf("Probe.GPU=%v != GPUAvailable()=%v", p.GPU, GPUAvailable())
}
s := p.String()
if len(s) == 0 || s[0] != 'b' {
t.Errorf("Snapshot.String() = %q; want prefix 'backend{...}'", s)
}
}
func TestEnvOverride(t *testing.T) {
// Saved current state.
prev := Default()
+10
View File
@@ -0,0 +1,10 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !cgo
package backend
// cgoLinked is false: CGO_ENABLED=0 at build time so no C-backed dispatch
// path is reachable in this binary. Resolve() will never return CGo.
const cgoLinked = false
+11
View File
@@ -0,0 +1,11 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo
package backend
// cgoLinked reports whether this package was built with CGO_ENABLED=1.
// When true, the C-backed dispatch paths in luxfi/crypto (libluxcrypto,
// libsecp256k1, blst, ckzg, ...) can be linked at runtime.
const cgoLinked = true
+40
View File
@@ -0,0 +1,40 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package backend
import (
"os"
"strings"
)
// LUX_GPU_DISABLE is the process-wide operator kill switch for GPU
// dispatch. Read exactly once at init; the value is then constant for
// the lifetime of the process. Set to a truthy value
// (1 / true / yes / on) to force every algorithm dispatcher to its CPU
// path regardless of whether a device is present.
//
// This is orthogonal to GPUAvailable() — that probe answers "is there a
// device", this knob answers "should we use it even if there is". Use
// cases: GPU driver regression in prod, A/B rollout where some racks
// stay on CPU, validators sharing a host with another tenant.
const envGPUDisable = "LUX_GPU_DISABLE"
var gpuDisabled = parseTruthy(os.Getenv(envGPUDisable))
// GPUDisabled reports whether the LUX_GPU_DISABLE kill switch is set.
// When true, GPUAvailable() returns false and IsGPU() therefore returns
// false, forcing every dispatcher onto its CPU path.
func GPUDisabled() bool { return gpuDisabled }
// parseTruthy mirrors the convention used by strconv.ParseBool but
// accepts a few extra human-friendly spellings. Empty / 0 / false / no /
// off → false; anything else → true.
func parseTruthy(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "0", "false", "no", "off":
return false
default:
return true
}
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package backend
import "testing"
func TestParseTruthy(t *testing.T) {
cases := map[string]bool{
"": false,
"0": false,
"false": false,
"FALSE": false,
"no": false,
"NO": false,
"off": false,
" 0 ": false,
"1": true,
"true": true,
"yes": true,
"on": true,
"YES": true,
"foo": true, // any non-falsy spelling counts as truthy
}
for in, want := range cases {
if got := parseTruthy(in); got != want {
t.Errorf("parseTruthy(%q) = %v; want %v", in, got, want)
}
}
}
func TestGPUDisabledStable(t *testing.T) {
// gpuDisabled is parsed once at init; repeated calls must agree.
v1 := GPUDisabled()
v2 := GPUDisabled()
if v1 != v2 {
t.Fatalf("GPUDisabled() not stable: %v then %v", v1, v2)
}
}
func TestGPUAvailableHonorsDisabled(t *testing.T) {
// When disabled, GPUAvailable must return false regardless of probe.
if GPUDisabled() && GPUAvailable() {
t.Fatal("GPUAvailable=true while GPUDisabled=true — kill switch ignored")
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package backend
import (
"log"
"sync"
"sync/atomic"
)
// FallbackReason is the low-cardinality enum recorded by RecordFallback.
// Keeping the set small and closed is deliberate — these values land in
// counter labels and log lines, and dynamic strings there are an
// observability anti-pattern (label explosion in Prometheus, log noise).
type FallbackReason uint32
const (
// FallbackDisabled — LUX_GPU_DISABLE is set; dispatcher routed to CPU.
FallbackDisabled FallbackReason = iota
// FallbackUnsupported — host driver reported no usable device
// (cudaGetDeviceCount == 0, Metal device init failed, etc.).
FallbackUnsupported
// FallbackProbeFailed — first-use byte-equality probe rejected
// the GPU path (e.g. FHE NTT (N,Q) convention mismatch).
FallbackProbeFailed
// FallbackBackendUnavailable — accel session itself failed to load
// or the linked plugin returned LUX_NOT_SUPPORTED.
FallbackBackendUnavailable
// FallbackABIMismatch — a Go init() ABI-size/offset check would have
// panicked, but a dispatcher recovered and downgraded to CPU. Should
// never fire in a released build; included for completeness.
FallbackABIMismatch
numFallbackReasons
)
// String returns the canonical low-cardinality reason name. These are
// the strings that appear in metrics labels and log lines.
func (r FallbackReason) String() string {
switch r {
case FallbackDisabled:
return "disabled"
case FallbackUnsupported:
return "unsupported"
case FallbackProbeFailed:
return "probe_failed"
case FallbackBackendUnavailable:
return "backend_unavailable"
case FallbackABIMismatch:
return "abi_mismatch"
default:
return "unknown"
}
}
var (
fallbackCounters [numFallbackReasons]uint64
fallbackLogOnce [numFallbackReasons]sync.Once
)
// RecordFallback increments the per-reason counter and emits exactly one
// log line per distinct (reason) over the lifetime of the process. The
// `where` string identifies the dispatcher site ("amm", "clob",
// "fhe_ntt") so an operator skimming the log can locate the surface
// that fell back without per-call spam.
func RecordFallback(reason FallbackReason, where string) {
if reason >= numFallbackReasons {
return
}
atomic.AddUint64(&fallbackCounters[reason], 1)
fallbackLogOnce[reason].Do(func() {
log.Printf("[crypto/backend] GPU fallback: reason=%s where=%s", reason, where)
})
}
// FallbackCounters returns a snapshot of the per-reason counters keyed
// by the canonical reason name. Suitable for Prometheus / Grafana with
// `reason` as a label.
func FallbackCounters() map[string]uint64 {
out := make(map[string]uint64, int(numFallbackReasons))
for i := FallbackReason(0); i < numFallbackReasons; i++ {
out[i.String()] = atomic.LoadUint64(&fallbackCounters[i])
}
return out
}
// resetFallbackForTest zeros every counter and reinitialises the
// once-per-reason log gate. Test-only; package-private.
func resetFallbackForTest() {
for i := range fallbackCounters {
atomic.StoreUint64(&fallbackCounters[i], 0)
fallbackLogOnce[i] = sync.Once{}
}
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package backend
import (
"testing"
)
func TestFallbackReasonString(t *testing.T) {
cases := map[FallbackReason]string{
FallbackDisabled: "disabled",
FallbackUnsupported: "unsupported",
FallbackProbeFailed: "probe_failed",
FallbackBackendUnavailable: "backend_unavailable",
FallbackABIMismatch: "abi_mismatch",
FallbackReason(99): "unknown",
}
for r, want := range cases {
if got := r.String(); got != want {
t.Errorf("FallbackReason(%d).String() = %q; want %q", r, got, want)
}
}
}
func TestRecordFallbackIncrements(t *testing.T) {
resetFallbackForTest()
t.Cleanup(resetFallbackForTest)
RecordFallback(FallbackDisabled, "amm")
RecordFallback(FallbackDisabled, "amm")
RecordFallback(FallbackUnsupported, "clob")
c := FallbackCounters()
if c["disabled"] != 2 {
t.Errorf("disabled counter = %d; want 2", c["disabled"])
}
if c["unsupported"] != 1 {
t.Errorf("unsupported counter = %d; want 1", c["unsupported"])
}
if c["probe_failed"] != 0 {
t.Errorf("probe_failed counter = %d; want 0", c["probe_failed"])
}
}
func TestRecordFallbackKeysAreLowCardinality(t *testing.T) {
resetFallbackForTest()
t.Cleanup(resetFallbackForTest)
// Even a million RecordFallback calls only ever land in five keys.
for i := 0; i < 1000; i++ {
RecordFallback(FallbackDisabled, "amm")
RecordFallback(FallbackUnsupported, "clob")
RecordFallback(FallbackProbeFailed, "fhe_ntt")
RecordFallback(FallbackBackendUnavailable, "amm")
RecordFallback(FallbackABIMismatch, "clob")
}
c := FallbackCounters()
if len(c) != int(numFallbackReasons) {
t.Fatalf("counter key set drifted: %d keys; want %d", len(c), numFallbackReasons)
}
for _, want := range []string{"disabled", "unsupported", "probe_failed", "backend_unavailable", "abi_mismatch"} {
if c[want] == 0 {
t.Errorf("expected non-zero count for %q", want)
}
}
}
func TestRecordFallbackOutOfRangeIsNoop(t *testing.T) {
resetFallbackForTest()
t.Cleanup(resetFallbackForTest)
RecordFallback(FallbackReason(99), "amm")
for k, v := range FallbackCounters() {
if v != 0 {
t.Errorf("counter %q got %d after out-of-range call; want 0", k, v)
}
}
}
func TestProbeSurfacesDisabledAndFallbacks(t *testing.T) {
resetFallbackForTest()
t.Cleanup(resetFallbackForTest)
RecordFallback(FallbackUnsupported, "amm")
p := Probe()
if p.Disabled != GPUDisabled() {
t.Errorf("Probe.Disabled=%v != GPUDisabled()=%v", p.Disabled, GPUDisabled())
}
if p.Fallbacks["unsupported"] != 1 {
t.Errorf("Probe.Fallbacks[unsupported]=%d; want 1", p.Fallbacks["unsupported"])
}
s := p.String()
if len(s) == 0 || s[0] != 'b' {
t.Errorf("Probe.String prefix wrong: %q", s)
}
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package backend
import "github.com/luxfi/crypto/internal/gpuhost"
// gpuLinked reports whether the GPU substrate is reachable right now.
// The first call triggers a one-time accel.Init() inside gpuhost; the
// answer is then cached for the lifetime of the process.
//
// Returns false when:
// - the binary was built without cgo (accel.Init returns ErrNoBackends)
// - accel initialised but found no Metal / CUDA / WebGPU device
// - the accel.Session allocation failed (driver / permission / OOM)
func gpuLinked() bool { return gpuhost.Available() }
+1 -1
View File
@@ -16,7 +16,7 @@ import (
)
func TestKeccak256BatchAcrossBackends(t *testing.T) {
inputs := make([][]byte, keccak.BatchThreshold+8)
inputs := make([][]byte, keccak256.BatchThreshold+8)
for i := range inputs {
buf := make([]byte, 32)
rand.Read(buf)
+108
View File
@@ -0,0 +1,108 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package backend
import (
"fmt"
"strings"
"github.com/luxfi/accel"
"github.com/luxfi/crypto/internal/gpuhost"
)
// Snapshot is the auditable, side-effect-free view of the backend layer at
// a single moment. Callers print one of these to make "GPU accelerated"
// claims falsifiable — the snapshot says exactly which substrate the
// dispatchers will reach for the next batch call.
type Snapshot struct {
// Default is the user-selected backend (Auto / Vanilla / CGo / GPU).
Default Backend
// Resolved is the concrete backend Resolved() would return right now,
// given the live CGo / GPU probes.
Resolved Backend
// CGo reports whether CGO_ENABLED was on at build time.
CGo bool
// GPU reports whether luxfi/accel found at least one device. Always
// false when Disabled is true, regardless of the underlying probe.
GPU bool
// Disabled reflects the LUX_GPU_DISABLE operator kill switch.
Disabled bool
// GPUBackend names the active accel backend ("metal" / "cuda" /
// "webgpu") or "" when no GPU is available.
GPUBackend string
// GPUDeviceCount is the number of devices visible to accel.
GPUDeviceCount int
// AccelVersion is the underlying accel library version string.
AccelVersion string
// Fallbacks is the per-reason fallback counter snapshot. Keys are
// the low-cardinality strings returned by FallbackReason.String().
Fallbacks map[string]uint64
}
// Probe returns the current backend Snapshot. Side-effect free except for
// the lazy accel.Init() inside the GPU probe — calling it before any
// algorithm dispatch is the canonical way to print "what would happen if
// I called Batch right now."
func Probe() Snapshot {
s := Snapshot{
Default: Default(),
Resolved: Resolved(),
CGo: CGoAvailable(),
GPU: GPUAvailable(),
Disabled: GPUDisabled(),
AccelVersion: accel.GetVersion(),
Fallbacks: FallbackCounters(),
}
if s.GPU {
if sess := gpuhost.Session(); sess != nil {
s.GPUBackend = sess.Backend().String()
}
s.GPUDeviceCount = len(accel.Devices())
}
return s
}
// String formats the snapshot as a single human-readable line suitable
// for startup banners and audit logs.
func (s Snapshot) String() string {
var b strings.Builder
fmt.Fprintf(&b, "backend{default=%s resolved=%s cgo=%t gpu=%t",
s.Default, s.Resolved, s.CGo, s.GPU)
if s.Disabled {
b.WriteString(" disabled=true")
}
if s.GPU {
fmt.Fprintf(&b, " gpu_backend=%s devices=%d", s.GPUBackend, s.GPUDeviceCount)
}
if s.AccelVersion != "" {
fmt.Fprintf(&b, " accel=%s", s.AccelVersion)
}
// Only emit the fallbacks block if at least one counter is non-zero
// so the common case stays one short line.
any := false
for _, v := range s.Fallbacks {
if v > 0 {
any = true
break
}
}
if any {
b.WriteString(" fallbacks=[")
first := true
for _, name := range []string{"disabled", "unsupported", "probe_failed", "backend_unavailable", "abi_mismatch"} {
v := s.Fallbacks[name]
if v == 0 {
continue
}
if !first {
b.WriteByte(' ')
}
fmt.Fprintf(&b, "%s=%d", name, v)
first = false
}
b.WriteByte(']')
}
b.WriteByte('}')
return b.String()
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
)
func batchVerifyGPU(pks []*PublicKey, msgs [][]byte, sigs []*Signature, out []bool) (bool, error) {
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
+1 -1
View File
@@ -10,7 +10,7 @@ import (
)
func batchGPU(pubs []PublicKey, msgs [][]byte, sigs [][]byte, out []bool) (bool, error) {
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
+17 -21
View File
@@ -1,35 +1,30 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package gpu exposes the runtime status of the GPU acceleration layer used
// by luxfi/crypto.
// Package gpu is the public probe surface for the GPU acceleration layer
// used by luxfi/crypto.
//
// In normal use, callers should NOT call this package directly. Each
// algorithm package (keccak, sha256, bls, mldsa, ...) decides at the call
// site whether to dispatch to the GPU based on backend.Default() and the
// batch threshold. This package is a single, narrow surface so consumers
// can probe the status of the layer for diagnostics and monitoring.
// It delegates every call to github.com/luxfi/crypto/backend — the canonical
// runtime substrate selector. Callers writing new code should prefer
// backend.Probe() / backend.GPUAvailable(); this package stays for back-compat
// with consumers that already import "github.com/luxfi/crypto/gpu".
package gpu
import (
"github.com/luxfi/accel"
"github.com/luxfi/crypto/internal/gpuhost"
"github.com/luxfi/crypto/backend"
)
// Available returns true when an accel session was successfully created
// and at least one backend is reporting available devices.
func Available() bool { return gpuhost.Available() }
// Available reports whether the GPU substrate is reachable. Equivalent to
// backend.GPUAvailable().
func Available() bool { return backend.GPUAvailable() }
// Backend returns the name of the active backend ("metal", "cuda", "webgpu",
// or "" when no GPU is available).
func Backend() string {
if !Available() {
return ""
}
return gpuhost.Session().Backend().String()
}
// Backend names the active accel backend ("metal" | "cuda" | "webgpu") or
// "" when no GPU is available. Equivalent to backend.Probe().GPUBackend.
func Backend() string { return backend.Probe().GPUBackend }
// Devices returns the list of devices visible to the accel layer.
// Devices returns the list of devices visible to the accel layer, or nil
// when no GPU is available.
func Devices() []accel.DeviceInfo {
if !Available() {
return nil
@@ -37,5 +32,6 @@ func Devices() []accel.DeviceInfo {
return accel.Devices()
}
// Version returns the underlying accel library version string.
// Version returns the underlying accel library version string. Equivalent
// to backend.Probe().AccelVersion.
func Version() string { return accel.GetVersion() }
+7 -13
View File
@@ -11,7 +11,6 @@ import (
"github.com/luxfi/accel/ops/code"
"github.com/luxfi/crypto/backend"
"github.com/luxfi/crypto/internal/gpuhost"
)
// HQC is code-based (Hamming Quasi-Cyclic): the hot path is GF(2)^N
@@ -54,20 +53,15 @@ const batchSizeThreshold = 4
// OpenMP at the C++ level. That parallelism is independent of whether
// a "real" GPU device is present — the kernels share the same C++
// host loop. We therefore key dispatch on:
// 1. backend.Resolve() does not explicitly request Vanilla (pure-Go).
// 1. backend.IsVanilla() is false — i.e. the caller did NOT explicitly
// pick the pure-Go fallback.
// 2. The batch is large enough to amortise the cgo crossover.
//
// The gpuhost.Session() call is left for symmetry with crypto/mlkem's
// dispatcher (and for future Metal/CUDA backend overrides) but its
// nil-ness is not treated as a hard skip.
// We do NOT consult a session handle here — accel/ops/code parallelises via
// OpenMP at the C++ level whether or not a Metal/CUDA device exists, so the
// presence of an accel session is irrelevant to this dispatcher.
func batchEncapsulateGPU(pubs []*PublicKey, cts []*Ciphertext, sss [][]byte) (bool, error) {
// `cgo` is always true here — this file only compiles under cgo
// (the GPU batch surface requires the C++ host library). The
// `gpuAvailable` argument tells backend.Resolve whether to
// prefer GPU over CGo; we treat either as a green light because
// the accel batch surface is faster than per-item PQClean even
// without a Metal/CUDA device.
if backend.Resolve(gpuhost.Available(), true) == backend.Vanilla {
if backend.IsVanilla() {
// Caller explicitly requested pure-Go (no PQClean) — no
// batch path available, fall through to CPU per-item.
return false, nil
@@ -148,7 +142,7 @@ func batchEncapsulateGPU(pubs []*PublicKey, cts []*Ciphertext, sss [][]byte) (bo
// batchDecapsulateGPU attempts GPU-batched HQC decapsulation.
// See batchEncapsulateGPU for the dispatch-gating rationale.
func batchDecapsulateGPU(sks []*PrivateKey, cts []*Ciphertext, sss [][]byte) (bool, error) {
if backend.Resolve(gpuhost.Available(), true) == backend.Vanilla {
if backend.IsVanilla() {
return false, nil
}
if len(sks) < batchSizeThreshold {
+4 -33
View File
@@ -66,36 +66,7 @@ func InitError() error {
return initErr
}
// Provenance captures, at the moment of the call, exactly which
// substrate the algorithm dispatchers will reach when they ask gpuhost
// for a session. This is the auditable evidence a reviewer asks for
// when they want to confirm a "GPU accelerated" claim is not vapor.
//
// The fields are intentionally narrow and observable:
//
// - AccelInitialised: did accel.Init() return without error.
// - DeviceAvailable: did accel report any device after init.
// - SessionLive: did we actually allocate a session.
//
// Algorithm packages publish their own thin layer on top of this that
// also reports whether the plugin's strong symbol for that algorithm
// is resolved (e.g. whether lux_slhdsa_verify_batch is the LUX_NOT_SUPPORTED
// stub or a strong override from a loaded plugin).
type Provenance struct {
AccelInitialised bool
DeviceAvailable bool
SessionLive bool
}
// Snapshot returns the current Provenance. Side-effect-free except for
// the lazy accel.Init() inside the underlying Init() — calling it before
// any algorithm dispatch is the canonical way to print "what would
// happen if I called Batch right now."
func Snapshot() Provenance {
Init()
return Provenance{
AccelInitialised: initErr == nil,
DeviceAvailable: available && sess != nil,
SessionLive: sess != nil,
}
}
// Diagnostic snapshots live in github.com/luxfi/crypto/backend (Probe()).
// gpuhost stays narrow: Init / Available / Session / InitError — the
// substrate primitives. Anything richer is a diagnostic question and
// belongs in backend, which is the canonical runtime substrate selector.
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// ErrNoBackends in that mode — the caller treats both shapes as "fall
// back to vanilla".
func batchGPU(inputs [][]byte, out [][Size]byte) (handled bool, err error) {
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
+4 -3
View File
@@ -5,7 +5,8 @@
//
// Dispatch path:
//
// backend.Resolve(gpuhost.Available(), false) → backend.GPU
// backend.IsGPU()
// ⇒ Resolved() picks the GPU substrate after probing CGo + GPU availability
// ⇒ accel.LatticeOps.MLDSAVerifyBatch / MLDSASignBatch on the shared session
// ⇒ luxcpp/lux-accel C API lux_mldsa_{verify,sign}_batch (under development)
// ⇒ when a backend plugin registers a strong substrate:
@@ -83,7 +84,7 @@ func batchVerifyGPU(pubs []*PublicKey, msgs [][]byte, sigs [][]byte, out []bool)
return false, nil
}
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
@@ -195,7 +196,7 @@ func batchSignGPU(privs []*PrivateKey, msgs [][]byte, sigs [][]byte) (bool, erro
return false, nil
}
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
+4 -3
View File
@@ -15,7 +15,7 @@
// circl reference, so we know the underlying library is intact.
// 2. Batch verify equivalence — feed a batch through BatchVerify and
// confirm it matches the per-element CPU oracle byte-for-byte. When a
// GPU plugin is registered (CRYPTO_BACKEND=gpu + gpuhost detects a
// GPU plugin is registered (CRYPTO_BACKEND=gpu + backend.GPUAvailable
// device) this exercises the real GPU substrate; otherwise it
// validates the goroutine-parallel CPU fallback.
// 3. Tamper detection — flip a bit and confirm both paths reject.
@@ -29,7 +29,8 @@ import (
"github.com/cloudflare/circl/sign/mldsa/mldsa44"
"github.com/luxfi/crypto/backend"
"github.com/luxfi/crypto/internal/gpuhost"
)
// genBatch44 produces n ML-DSA-44 keypairs + signs distinct messages with
@@ -137,7 +138,7 @@ func TestMLDSA44_BatchEquivalence_CPU_GPU(t *testing.T) {
t.Fatalf("batchVerifyGPU: %v", err)
}
if dispatched {
t.Logf("GPU dispatch active (gpuhost.Available=%v)", gpuhost.Available())
t.Logf("GPU dispatch active (gpu=%v)", backend.GPUAvailable())
for i := range cpu {
if cpu[i] != gpu[i] {
t.Fatalf("CPU/GPU mismatch at[%d]: cpu=%v gpu=%v", i, cpu[i], gpu[i])
+3 -2
View File
@@ -21,7 +21,8 @@ import (
"github.com/cloudflare/circl/sign/mldsa/mldsa87"
"github.com/luxfi/crypto/backend"
"github.com/luxfi/crypto/internal/gpuhost"
)
func genBatch87(t testing.TB, n int) (pubs []*PublicKey, msgs [][]byte, sigs [][]byte) {
@@ -116,7 +117,7 @@ func TestMLDSA87_BatchEquivalence_CPU_GPU(t *testing.T) {
t.Fatalf("batchVerifyGPU: %v", err)
}
if dispatched {
t.Logf("GPU dispatch active (gpuhost.Available=%v)", gpuhost.Available())
t.Logf("GPU dispatch active (gpu=%v)", backend.GPUAvailable())
for i := range cpu {
if cpu[i] != gpu[i] {
t.Fatalf("CPU/GPU mismatch at[%d]: cpu=%v gpu=%v", i, cpu[i], gpu[i])
+8 -13
View File
@@ -7,7 +7,6 @@ import (
"sync/atomic"
"github.com/luxfi/crypto/backend"
"github.com/luxfi/crypto/internal/gpuhost"
)
// DispatchTier identifies which path the ML-DSA batch dispatchers will
@@ -67,22 +66,18 @@ type Provenance struct {
// at runtime. Once a real batch dispatch lands a strong-symbol
// observation, follow-up calls report TierGPUSubstrate.
func GetProvenance() Provenance {
snap := gpuhost.Snapshot()
bp := backend.Probe()
p := Provenance{
AccelInitialised: snap.AccelInitialised,
DeviceAvailable: snap.DeviceAvailable,
// backend.Probe.GPU is the conjunction the prior gpuhost.Snapshot
// tracked as (AccelInitialised && DeviceAvailable && SessionLive).
// Surface it under both legacy field names for consumers that
// already pattern-match on AccelInitialised / DeviceAvailable.
AccelInitialised: bp.GPU,
DeviceAvailable: bp.GPU,
BatchThresholdN: BatchThreshold,
ConcurrentThresholdN: concurrentBatchThreshold,
}
if !snap.AccelInitialised {
p.Tier = TierGoroutineParallelCPU
return p
}
if backend.Resolve(snap.DeviceAvailable, false) != backend.GPU {
p.Tier = TierGoroutineParallelCPU
return p
}
if !snap.SessionLive {
if bp.Resolved != backend.GPU {
p.Tier = TierGoroutineParallelCPU
return p
}
+2 -2
View File
@@ -13,7 +13,7 @@ import (
// Other modes fall through.
func batchEncapsulateGPU(pubs []*PublicKey, cts [][]byte, sss [][]byte) (bool, error) {
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
@@ -74,7 +74,7 @@ func batchEncapsulateGPU(pubs []*PublicKey, cts [][]byte, sss [][]byte) (bool, e
}
func batchDecapsulateGPU(sks []*PrivateKey, cts [][]byte, sss [][]byte) (bool, error) {
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
+1 -1
View File
@@ -10,7 +10,7 @@ import (
)
func batchVerifyGPU(pubkeys, msgHashes, sigs [][]byte, out []bool) (bool, error) {
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
+1 -1
View File
@@ -10,7 +10,7 @@ import (
)
func batchGPU(inputs [][]byte, out [][Size]byte) (bool, error) {
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
+3 -3
View File
@@ -9,7 +9,7 @@
// Reaches a Metal/CUDA plugin when one is registered, otherwise falls
// through to (2). The interface is the same wire as ML-DSA dispatch:
//
// backend.Resolve(gpuhost.Available(), false) → backend.GPU
// backend.IsGPU()
// ⇒ accel.LatticeOps.SLHDSA{Sign,Verify}Batch on the shared session
// ⇒ luxcpp/lux-accel C API lux_slhdsa_{sign,verify}_batch
// ⇒ when a backend plugin registers a strong override:
@@ -175,7 +175,7 @@ func VerifyBatchGPU(pubs []*PublicKey, msgs, sigs [][]byte, out []bool) (bool, e
return false, nil
}
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
@@ -368,7 +368,7 @@ func SignBatchGPU(privs []*PrivateKey, msgs, sigs [][]byte) (bool, error) {
if len(msgs) != len(privs) || len(sigs) != len(privs) {
return false, nil
}
if backend.Resolve(gpuhost.Available(), false) != backend.GPU {
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
+3 -2
View File
@@ -33,7 +33,8 @@ import (
"testing"
"github.com/luxfi/crypto/backend"
"github.com/luxfi/crypto/internal/gpuhost"
)
// genSignBatch192f produces n SLH-DSA-192f keypairs ready for SignBatch.
@@ -97,7 +98,7 @@ func TestSLHDSASignBatch_GPU_Path(t *testing.T) {
t.Fatalf("SignBatchGPU: %v", err)
}
if dispatched {
t.Logf("GPU dispatch active (gpuhost.Available=%v)", gpuhost.Available())
t.Logf("GPU dispatch active (gpu=%v)", backend.GPUAvailable())
// GPU-produced signatures MUST verify under the CPU oracle.
// This is the round-trip that catches GPU-output corruption.
for i := 0; i < n; i++ {
+5 -4
View File
@@ -9,7 +9,8 @@ import (
"testing"
"github.com/luxfi/crypto/backend"
"github.com/luxfi/crypto/internal/gpuhost"
)
// genBatch produces n SLH-DSA-192f keypairs + signs distinct messages with
@@ -70,8 +71,8 @@ func TestSLHDSABatchEquivalence_CPU_GPU(t *testing.T) {
}
}
// Force GPU resolution to attempt the GPU path. backend.Resolve
// gates on gpuhost.Available() so this only actually goes to GPU
// Force GPU resolution to attempt the GPU path. backend.IsGPU()
// gates on backend.GPUAvailable() so this only actually goes to GPU
// when a backend plugin is loaded; otherwise it falls through and
// VerifyBatchGPU returns (false, nil).
prevBackend := backend.Default()
@@ -85,7 +86,7 @@ func TestSLHDSABatchEquivalence_CPU_GPU(t *testing.T) {
}
if dispatched {
t.Logf("GPU dispatch executed (backend=%s, gpuhost.Available=%v)", backend.Default(), gpuhost.Available())
t.Logf("GPU dispatch executed (backend=%s, gpu=%v)", backend.Default(), backend.GPUAvailable())
for i := range cpu {
if cpu[i] != gpu[i] {
t.Fatalf("CPU/GPU mismatch at[%d]: cpu=%v gpu=%v", i, cpu[i], gpu[i])
+13 -15
View File
@@ -7,7 +7,6 @@ import (
"sync/atomic"
"github.com/luxfi/crypto/backend"
"github.com/luxfi/crypto/internal/gpuhost"
)
// DispatchTier identifies which path the SLH-DSA batch dispatchers will
@@ -115,27 +114,26 @@ type Provenance struct {
// a stock keypair (no allocation cost on the caller side) and caches
// the result for the lifetime of the process.
func GetProvenance() Provenance {
snap := gpuhost.Snapshot()
bp := backend.Probe()
p := Provenance{
AccelInitialised: snap.AccelInitialised,
DeviceAvailable: snap.DeviceAvailable,
// backend.Probe.GPU is true only when accel initialised AND a
// session is live AND at least one device is visible — the same
// three-way conjunction the prior gpuhost.Snapshot() tracked
// individually. We surface the conjunction here as one bit per
// historical field for backward compatibility with consumers
// that already pattern-match on AccelInitialised/DeviceAvailable.
AccelInitialised: bp.GPU,
DeviceAvailable: bp.GPU,
SignBatchThresholdN: SignBatchThreshold,
ConcurrentSignThresholdN: concurrentSignThreshold,
}
// Walk the dispatch tiers from the strongest evidence downward,
// mirroring the order in SignBatch / VerifyBatch.
if !snap.AccelInitialised {
p.Tier = TierGoroutineParallelCPU
return p
}
if backend.Resolve(snap.DeviceAvailable, false) != backend.GPU {
// Vanilla profile or no device — CPU path is the only option.
p.Tier = TierGoroutineParallelCPU
return p
}
if !snap.SessionLive {
// mirroring the order in SignBatch / VerifyBatch. Resolved() returns
// the same answer regardless of whether the user explicitly set
// CRYPTO_BACKEND or left it at Auto.
if bp.Resolved != backend.GPU {
p.Tier = TierGoroutineParallelCPU
return p
}