thresholdvm: GPU substrate bridge via dlopen+dlsym to mpcvm host launchers

Wires the M-Chain ThresholdVM Go package to per-backend GPU plugins
in lux-private/gpu-kernels/backends/<X>/. Same 3-file pattern as
chains/evm/cevm but with runtime dlopen instead of compile-time
pkg-config. Bridge is a strict positive overlay — never a hard dep.

Files (under thresholdvm/):
- thresholdvm_gpu.go       (cgo)  : dlopen+dlsym; GPUBackend struct with
                                    CeremonyApply, KeyShareApply,
                                    ContributionApply, MPCTransition
- thresholdvm_gpu_nocgo.go (!cgo) : ErrGPUNotAvailable stub mirroring
                                    the cgo type surface
- backend.go               (cgo)  : init() probe — cuda -> hip -> metal
                                    -> vulkan -> webgpu, candidate paths
                                    from LUXCPP_PREFIX +
                                    LUX_PRIVATE_GPU_KERNELS_DIR
- thresholdvm_gpu_test.go         : 4 tests across both build flavors

Symbols (per backend X in {cuda, hip, metal, vulkan, webgpu}):
- lux_<X>_mpcvm_ceremony_apply  (begin/cancel + contribution dedup)
- lux_<X>_mpcvm_ceremony_sweep  (DKG finalize -> key share assignment)
- lux_<X>_mpcvm_compute_leaves  (parallel keccak per slot)
- lux_<X>_mpcvm_compose_root    (serial fold + epoch advance)

Wire structs (GPU- prefix to avoid collision with the domain KeyShare
interface in protocols.go) match ops/mpcvm/cuda/mpcvm_kernels_common.cuh
__align__(16) layouts byte-for-byte. sizeof asserts at package init()
+ TestGPULayoutSizes — any drift fails fast.

Corona = R-LWE threshold sig (active PQ variant per project memory).
Pulsar = threshold ML-DSA. Magnetar = threshold SLH-DSA (not built yet).
Bridge dispatches Corona on CeremonyOp.kind in {Corona DKG, Corona Sign}.

Constraints honored:
- Existing thresholdvm vm.go core paths NOT modified — bridge is opt-in
- Both build flavors compile: go build + CGO_ENABLED=0 go build
- Tests pass: 4/4 under cgo, 4/4 under nocgo, race-clean

Env knobs:
- LUX_THRESHOLDVM_GPU_BACKEND=cuda|hip|metal|vulkan|webgpu|none
- LUX_THRESHOLDVM_GPU_DEBUG=1
- LUXCPP_PREFIX, LUX_PRIVATE_GPU_KERNELS_DIR
This commit is contained in:
2026-06-05 15:00:10 -07:00
parent 4dd244e903
commit 7c31eb595d
4 changed files with 1381 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo
package thresholdvm
// dlopen probe for the MPCVM GPU plugin. Runs once at package init() and
// pins the resolved GPUBackend into the gpuBackend singleton. Probe order
// matches the LP-186 substrate priority: native (CUDA, HIP, Metal) over
// portable (Vulkan, WebGPU), discrete over UMA.
//
// cuda → hip → metal → vulkan → webgpu
//
// Each backend has a small set of candidate dylib names. We try the
// LUXCPP_PREFIX install path first, then the lux-gpu-kernels build-tree
// path, then the bare leaf-name (relying on the dynamic linker's default
// search path: $DYLD_LIBRARY_PATH, $LD_LIBRARY_PATH, system dirs).
//
// LUX_THRESHOLDVM_GPU_BACKEND overrides the probe — when set to a backend
// name we try ONLY that one. When set to "none" we skip the probe entirely.
//
// The probe is intentionally silent on failure: a host with no GPU still
// boots; thresholdvm just runs on the CPU reference (the existing
// protocol/, factory.go, executor.go state machine, unchanged by this
// bridge). Failure paths log to stderr at most once at init() under
// LUX_THRESHOLDVM_GPU_DEBUG=1 for diagnostics.
import (
"fmt"
"os"
"path/filepath"
)
// init runs the dlopen probe exactly once at package load. The size
// assertions run first so any wire-struct drift fails fast at process
// start instead of producing silently-wrong kernel results.
func init() {
gpuBackendOnce.Do(func() {
assertSizes()
gpuBackend = probeGPUBackend()
if debugProbe() {
if gpuBackend == nil || !gpuBackend.IsAvailable() {
fmt.Fprintln(os.Stderr,
"[lux-thresholdvm-gpu] no plugin resolved, falling back to CPU")
} else {
fmt.Fprintf(os.Stderr,
"[lux-thresholdvm-gpu] resolved backend=%s path=%s\n",
gpuBackend.Kind, gpuBackend.Path)
}
}
})
}
func debugProbe() bool {
v := os.Getenv("LUX_THRESHOLDVM_GPU_DEBUG")
return v == "1" || v == "true"
}
// probeGPUBackend walks the canonical backend priority list and returns the
// first one that dlopens AND resolves the required symbol pair. Returns
// nil when nothing matches (CPU-only mode).
func probeGPUBackend() *GPUBackend {
if kind, set := envBackendOverride(); set {
if kind == GPUBackendNone {
return nil
}
return tryLoadPlugin(kind, candidatesFor(kind)...)
}
order := []GPUBackendKind{
GPUBackendCUDA,
GPUBackendHIP,
GPUBackendMetal,
GPUBackendVulkan,
GPUBackendWebGPU,
}
for _, kind := range order {
if b := tryLoadPlugin(kind, candidatesFor(kind)...); b != nil {
return b
}
}
return nil
}
// candidatesFor returns up to three dylib filename candidates for the given
// backend, ordered: LUXCPP_PREFIX/lib install path, lux-gpu-kernels build-tree
// path under LUX_PRIVATE_GPU_KERNELS_DIR, then bare leaf-name for the
// dynamic-linker default search path.
//
// On darwin the convention is libluxgpu_backend_<X>.dylib; on linux it's
// .so. WebGPU has both .dylib (when built locally on macOS) and .so
// (linux), so we include both extensions in its candidate list to maximize
// dlopen hit rate without per-platform compile-time branching.
func candidatesFor(kind GPUBackendKind) []string {
leaves := backendDylibLeaves(kind)
out := make([]string, 0, len(leaves)*3)
prefix := os.Getenv("LUXCPP_PREFIX")
if prefix == "" {
if home := os.Getenv("HOME"); home != "" {
prefix = filepath.Join(home, "work", "luxcpp", "install")
}
}
for _, leaf := range leaves {
if prefix != "" {
out = append(out, filepath.Join(prefix, "lib", leaf))
}
}
if kernels := os.Getenv("LUX_PRIVATE_GPU_KERNELS_DIR"); kernels != "" {
for _, leaf := range leaves {
// build/<backend>_backend/<leaf> is the cmake output convention
// for both metal_backend/ and webgpu_backend/.
sub := backendBuildDirname(kind)
if sub != "" {
out = append(out, filepath.Join(kernels, "build", sub, leaf))
}
}
} else if home := os.Getenv("HOME"); home != "" {
// Default lux-private path per the project memory note (2026-05-28):
// "luxcpp/metal + luxcpp/webgpu ARCHIVED. Plugin source + shaders
// now at lux-private/gpu-kernels/{metal,webgpu,kernels/...}".
root := filepath.Join(home, "work", "lux-private", "gpu-kernels")
for _, leaf := range leaves {
sub := backendBuildDirname(kind)
if sub != "" {
out = append(out, filepath.Join(root, "build", sub, leaf))
}
}
}
// Bare leaf names — dynamic-linker default search picks them up if
// LD_LIBRARY_PATH / DYLD_LIBRARY_PATH or /usr/local/lib has them.
out = append(out, leaves...)
return out
}
// backendDylibLeaves returns the OS-appropriate leaf filenames for one
// backend. WebGPU has two valid extensions across platforms — we include
// both so the same probe works on macOS and linux.
func backendDylibLeaves(kind GPUBackendKind) []string {
switch kind {
case GPUBackendCUDA:
return []string{"libluxgpu_backend_cuda.so"}
case GPUBackendHIP:
return []string{"libluxgpu_backend_hip.so"}
case GPUBackendMetal:
return []string{"libluxgpu_backend_metal.dylib"}
case GPUBackendVulkan:
return []string{
"libluxgpu_backend_vulkan.so",
"libluxgpu_backend_vulkan.dylib",
}
case GPUBackendWebGPU:
return []string{
"libluxgpu_backend_webgpu.dylib",
"libluxgpu_backend_webgpu.so",
}
default:
return nil
}
}
// backendBuildDirname maps each backend to its cmake build subdirectory
// under lux-private/gpu-kernels/build/.
func backendBuildDirname(kind GPUBackendKind) string {
switch kind {
case GPUBackendCUDA:
return "cuda_backend"
case GPUBackendHIP:
return "hip_backend"
case GPUBackendMetal:
return "metal_backend"
case GPUBackendVulkan:
return "vulkan_backend"
case GPUBackendWebGPU:
return "webgpu_backend"
default:
return ""
}
}
+851
View File
@@ -0,0 +1,851 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo
package thresholdvm
// GPU bridge — dlopen + dlsym substrate for the MPCVM state-machine kernels.
//
// This file binds Go to the host launchers exported by the per-backend GPU
// plugins in lux-private/gpu-kernels/backends/<X>/. Unlike the cevm bridge
// which links statically through pkg-config (libevm + libevm-gpu), the
// thresholdvm bridge is a runtime overlay: zero compile-time dependency on
// the plugin, no pkg-config, no link-time resolution. Either the dlopen
// succeeds at init() and GPUBackend is populated, or it doesn't and
// callers fall back to the CPU reference (the Go state machine in
// protocol/, factory.go, executor.go — unchanged).
//
// Symbols expected per backend (host launchers in
// lux-private/gpu-kernels/backends/<X>/src/mpcvm_launchers.{cpp,mm} and
// the kernel TUs in lux-private/gpu-kernels/ops/mpcvm/<X>/):
//
// lux_<X>_mpcvm_ceremony_apply (begin/cancel + contribution dedup)
// lux_<X>_mpcvm_ceremony_sweep (DKG finalize -> key share assignment)
// lux_<X>_mpcvm_compute_leaves (parallel keccak per slot)
// lux_<X>_mpcvm_compose_root (serial fold + epoch advance)
//
// where <X> ∈ {cuda, hip, metal, vulkan, webgpu}. The Go bridge exposes
// the four MPC state-machine ops requested by the substrate contract:
//
// GPUBackend.CeremonyApply → ceremony_apply (ceremony ops only)
// GPUBackend.KeyShareApply → ceremony_sweep (DKG finalize path)
// GPUBackend.ContributionApply → ceremony_apply (contribution ops only)
// GPUBackend.MPCTransition → compute_leaves + compose_root pair
//
// The same ceremony_apply launcher is used twice with disjoint ops because
// the MPC state machine separates "ceremony admin" (begin/cancel) from
// "round contribution" (per-party payload) — both flow through the same
// dedup-and-write GPU kernel by design (see ops/mpcvm/cuda/mpcvm_ceremony.cu).
//
// Corona = R-LWE threshold (the PQ variant per project memory). Pulsar =
// threshold ML-DSA. Magnetar = threshold SLH-DSA (not built yet). When the
// caller invokes CeremonyApply with a CeremonyOp whose kind is kKindCoronaDkg
// or kKindCoronaSign, the GPU dispatches the Corona path; the kernel
// dispatch table is in ops/mpcvm/<X>/mpcvm_corona.cu (or .metal, etc.).
/*
#cgo CFLAGS: -Wno-unused-parameter
#cgo darwin LDFLAGS: -ldl
#cgo linux LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
// =============================================================================
// Function-pointer typedefs — match the extern "C" launcher signatures in
// lux-private/gpu-kernels/backends/<X>/src/mpcvm_launchers.{cpp,mm}.
//
// All pointers are HOST pointers. The launcher wraps them into the backend's
// native buffer type (MTLBuffer, VkBuffer, cudaMalloc, etc.) and uploads /
// downloads via memcpy on UMA backends or H2D/D2H on discrete backends.
//
// Return code: 0 = OK; non-zero = launcher-defined error (1 invalid arg,
// 2 no device, 3 pipeline build, 4 dispatch, 5 readback, 6 submit).
// =============================================================================
typedef int (*lux_mpcvm_ceremony_apply_fn)(
const void* desc,
const void* ceremony_ops,
const void* contribution_ops,
void* ceremonies,
void* contributions,
void* ceremony_applied_out,
void* contribution_applied_out,
uint32_t ceremony_count,
uint32_t contribution_count,
uint64_t next_contribution_id_in,
void* stream);
typedef int (*lux_mpcvm_ceremony_sweep_fn)(
const void* desc,
void* ceremonies,
void* key_shares,
void* contributions,
void* round_advance_out,
void* finalized_out,
void* failed_out,
uint32_t ceremony_count,
uint32_t key_share_count,
uint32_t contribution_count,
uint64_t next_share_id_in,
void* stream);
typedef int (*lux_mpcvm_compute_leaves_fn)(
const void* ceremonies,
const void* shares,
const void* contributions,
void* ceremony_leaf_hashes,
void* share_leaf_hashes,
void* contribution_leaf_hashes,
void* active_count_out,
void* finalized_count_out,
void* failed_count_out,
void* share_count_out,
void* ceremony_used_mask,
void* share_used_mask,
void* contribution_used_mask,
uint32_t ceremony_count,
uint32_t share_count,
uint32_t contribution_count,
void* stream);
typedef int (*lux_mpcvm_compose_root_fn)(
const void* desc,
const void* ceremony_leaf_hashes,
const void* share_leaf_hashes,
const void* contribution_leaf_hashes,
const void* ceremony_used_mask,
const void* share_used_mask,
const void* contribution_used_mask,
const void* active_count_in,
const void* finalized_count_in,
const void* failed_count_in,
const void* share_count_in,
void* state,
void* result,
uint32_t ceremony_count,
uint32_t share_count,
uint32_t contribution_count,
void* stream);
// Thin call shims — Go can't call a C function pointer directly.
static int call_ceremony_apply(
void* fn, const void* desc, const void* cops, const void* nops,
void* cer, void* con, void* capp, void* napp,
uint32_t cc, uint32_t nc, uint64_t next_cont, void* stream)
{
return ((lux_mpcvm_ceremony_apply_fn)fn)(
desc, cops, nops, cer, con, capp, napp, cc, nc, next_cont, stream);
}
static int call_ceremony_sweep(
void* fn, const void* desc,
void* cer, void* ks, void* con,
void* ra, void* fin, void* fai,
uint32_t cc, uint32_t kc, uint32_t nc, uint64_t next_share, void* stream)
{
return ((lux_mpcvm_ceremony_sweep_fn)fn)(
desc, cer, ks, con, ra, fin, fai, cc, kc, nc, next_share, stream);
}
static int call_compute_leaves(
void* fn,
const void* cer, const void* ks, const void* con,
void* cl, void* sl, void* nl,
void* ac, void* fc, void* fac, void* sc,
void* cm, void* sm, void* nm,
uint32_t cc, uint32_t kc, uint32_t nc, void* stream)
{
return ((lux_mpcvm_compute_leaves_fn)fn)(
cer, ks, con, cl, sl, nl, ac, fc, fac, sc, cm, sm, nm,
cc, kc, nc, stream);
}
static int call_compose_root(
void* fn,
const void* desc,
const void* cl, const void* sl, const void* nl,
const void* cm, const void* sm, const void* nm,
const void* ac, const void* fc, const void* fac, const void* sc,
void* state, void* result,
uint32_t cc, uint32_t kc, uint32_t nc, void* stream)
{
return ((lux_mpcvm_compose_root_fn)fn)(
desc, cl, sl, nl, cm, sm, nm, ac, fc, fac, sc, state, result,
cc, kc, nc, stream);
}
// dlsym helper — returns NULL when symbol is missing. Callers check.
static void* lux_dlsym(void* handle, const char* name) {
if (handle == NULL) return NULL;
dlerror();
return dlsym(handle, name);
}
// dlopen wrapper — try multiple candidate filenames in order. Returns NULL
// if every candidate fails. The dlopen mode is RTLD_NOW|RTLD_LOCAL: NOW so
// any missing symbol fails fast at load time, LOCAL so the plugin's
// internal symbols don't pollute the global namespace.
static void* lux_dlopen_first(const char* a, const char* b, const char* c) {
void* h = NULL;
if (a && *a) { h = dlopen(a, RTLD_NOW|RTLD_LOCAL); if (h) return h; }
if (b && *b) { h = dlopen(b, RTLD_NOW|RTLD_LOCAL); if (h) return h; }
if (c && *c) { h = dlopen(c, RTLD_NOW|RTLD_LOCAL); if (h) return h; }
return NULL;
}
*/
import "C"
import (
"errors"
"fmt"
"os"
"runtime"
"sync"
"unsafe"
)
// ErrGPUNotAvailable is returned by GPUBackend methods when no plugin was
// resolved at init() time. Callers check this to fall back to the CPU
// reference (the protocol/ + executor.go state machine, which is unchanged
// by this bridge).
var ErrGPUNotAvailable = errors.New("thresholdvm: GPU backend not available (no plugin dlopened)")
// GPUBackendKind is the resolved plugin family. Matches the dlopen probe
// order in backend.go: cuda → hip → metal → vulkan → webgpu.
type GPUBackendKind uint8
const (
GPUBackendNone GPUBackendKind = 0
GPUBackendCUDA GPUBackendKind = 1
GPUBackendHIP GPUBackendKind = 2
GPUBackendMetal GPUBackendKind = 3
GPUBackendVulkan GPUBackendKind = 4
GPUBackendWebGPU GPUBackendKind = 5
)
// String returns the launcher prefix used in symbol resolution.
func (k GPUBackendKind) String() string {
switch k {
case GPUBackendCUDA:
return "cuda"
case GPUBackendHIP:
return "hip"
case GPUBackendMetal:
return "metal"
case GPUBackendVulkan:
return "vulkan"
case GPUBackendWebGPU:
return "webgpu"
default:
return "none"
}
}
// =============================================================================
// Wire structs — byte-equivalent to ops/mpcvm/cuda/mpcvm_kernels_common.cuh.
//
// The struct layout MUST match the device-side __align__(16) declarations.
// Go's natural field alignment delivers the exact same packing on amd64 and
// arm64 (verified by the unsafe.Sizeof asserts in init()).
//
// Sizes (post __align__(16) tail padding):
//
// Ceremony = 128
// KeyShare = 368
// Contribution = 432
// MPCVMState = 160
// MPCVMRoundDescriptor = 96
// CeremonyOp = 96
// ContributionOp = 416
// MPCVMTransitionResult = 176
// =============================================================================
// GPUCeremony is the on-GPU ceremony state. 128 bytes, __align__(16). The
// GPU prefix distinguishes the wire mirror from the domain-level Ceremony
// types in protocols.go / runtime/. ONLY the GPU bridge ever touches these.
type GPUCeremony struct {
CeremonyID uint64
StartedAtNs uint64
DeadlineNs uint64
ParticipantsBitmap uint64
Kind uint32
Round uint32
Threshold uint32
TotalParticipants uint32
Status uint32
ContributionCount uint32
Subject [32]byte
CeremonySeed [32]byte
_ [8]byte // tail pad to 128
}
// GPUKeyShare is the on-GPU key share record. 368 bytes, __align__(16).
type GPUKeyShare struct {
ShareID uint64
CeremonyID uint64
HolderAddr uint64
Scheme uint32
HolderIndex uint32
ShareDataLen uint32
Occupied uint32
ShareData [320]byte
Pad0 uint64
}
// GPUContribution is the on-GPU contribution record. 432 bytes, __align__(16).
type GPUContribution struct {
ContributionID uint64
CeremonyID uint64
HolderAddr uint64
Round uint32
HolderIndex uint32
PayloadLen uint32
Status uint32
Payload [384]byte
Pad0 uint64
}
// GPUMPCVMState is the on-GPU substrate state. 160 bytes, __align__(16).
type GPUMPCVMState struct {
CurrentEpoch uint64
NowNs uint64
ActiveCeremonyCount uint32
FinalizedCeremonyCount uint32
FailedCeremonyCount uint32
KeyShareCount uint32
CeremonyRoot [32]byte
KeyShareRoot [32]byte
ContributionRoot [32]byte
MPCVMStateRoot [32]byte
}
// GPUMPCVMRoundDescriptor describes one round's input envelope. 96 bytes.
type GPUMPCVMRoundDescriptor struct {
ChainID uint64
Round uint64
TimestampNs uint64
Epoch uint64
Mode uint32
CeremonyOpCount uint32
ContributionOpCount uint32
ClosingFlag uint32
Pad0 uint32
Pad1 uint32
Pad2 uint64
ParentStateRoot [32]byte
}
// GPUCeremonyOp is one inbound ceremony op (begin/cancel). 96 bytes.
type GPUCeremonyOp struct {
CeremonyID uint64
DeadlineNs uint64
Kind uint32
CeremonyKind uint32
Threshold uint32
TotalParticipants uint32
Subject [32]byte
CeremonySeed [32]byte
}
// GPUContributionOp is one inbound contribution payload. 416 bytes.
type GPUContributionOp struct {
CeremonyID uint64
HolderAddr uint64
Round uint32
HolderIndex uint32
PayloadLen uint32
Pad0 uint32
Payload [384]byte
}
// GPUMPCVMTransitionResult is the transition envelope written by
// compose_root. 176 bytes.
type GPUMPCVMTransitionResult struct {
Status uint32
CeremonyApplyCount uint32
ContributionApplyCount uint32
FinalizedThisRound uint32
FailedThisRound uint32
ActiveCeremonyCount uint32
KeyShareCount uint32
RoundAdvanceCount uint32
Epoch uint64
NowNs uint64
CeremonyRoot [32]byte
KeyShareRoot [32]byte
ContributionRoot [32]byte
MPCVMStateRoot [32]byte
}
// =============================================================================
// Layout asserts — pin Go-side sizeof() to the device-side __align__(16)
// values. Any drift between the host structs and the device structs would
// produce silently-wrong results (the kernel reads bytes at fixed offsets),
// so any mismatch must fail at init() time. These are documented again in
// the .cuh peer file ops/mpcvm/cuda/mpcvm_kernels_common.cuh.
// =============================================================================
const (
sizeCeremony = 128
sizeKeyShare = 368
sizeContribution = 432
sizeMPCVMState = 160
sizeMPCVMRoundDescriptor = 96
sizeCeremonyOp = 96
sizeContributionOp = 416
sizeMPCVMTransitionResult = 176
)
func assertSizes() {
type pair struct {
name string
want int
got uintptr
}
checks := []pair{
{"GPUCeremony", sizeCeremony, unsafe.Sizeof(GPUCeremony{})},
{"GPUKeyShare", sizeKeyShare, unsafe.Sizeof(GPUKeyShare{})},
{"GPUContribution", sizeContribution, unsafe.Sizeof(GPUContribution{})},
{"GPUMPCVMState", sizeMPCVMState, unsafe.Sizeof(GPUMPCVMState{})},
{"GPUMPCVMRoundDescriptor", sizeMPCVMRoundDescriptor, unsafe.Sizeof(GPUMPCVMRoundDescriptor{})},
{"GPUCeremonyOp", sizeCeremonyOp, unsafe.Sizeof(GPUCeremonyOp{})},
{"GPUContributionOp", sizeContributionOp, unsafe.Sizeof(GPUContributionOp{})},
{"GPUMPCVMTransitionResult", sizeMPCVMTransitionResult, unsafe.Sizeof(GPUMPCVMTransitionResult{})},
}
for _, c := range checks {
if int(c.got) != c.want {
panic(fmt.Sprintf(
"thresholdvm: GPU layout mismatch — %s sizeof=%d want=%d. "+
"Rebuild gpu-kernels (ops/mpcvm/cuda/mpcvm_kernels_common.cuh) "+
"or update the Go struct.", c.name, c.got, c.want))
}
}
}
// =============================================================================
// GPUBackend — dlopen'd plugin handle + resolved function pointers.
//
// Constructed exactly once at init() (backend.go). Concurrent reads are safe
// (the symbol table is immutable after init). Concurrent calls into the same
// launcher are safe because the launchers themselves are stateless modulo
// the singleton MTL/cuda/vulkan context which holds its own mutex.
// =============================================================================
// GPUBackend is the resolved plugin substrate. Zero value = not available.
type GPUBackend struct {
Kind GPUBackendKind
Path string // dlopen'd library path, for diagnostics
handle unsafe.Pointer // dlopen handle; nil = no plugin
// Resolved symbols. Nil = symbol missing in plugin (launcher disabled).
fnCeremonyApply unsafe.Pointer
fnCeremonySweep unsafe.Pointer
fnComputeLeaves unsafe.Pointer
fnComposeRoot unsafe.Pointer
}
var (
gpuBackend *GPUBackend
gpuBackendOnce sync.Once
)
// Backend returns the resolved GPU plugin. nil means no plugin was loaded.
func Backend() *GPUBackend {
return gpuBackend
}
// IsAvailable reports whether the bridge has a usable plugin with at least
// the ceremony_apply and ceremony_sweep launchers resolved. compute_leaves
// and compose_root are required for MPCTransition but are checked
// per-method to allow partial GPU coverage when a plugin ships fewer
// symbols (e.g. an early Vulkan port).
func (g *GPUBackend) IsAvailable() bool {
if g == nil {
return false
}
return g.handle != nil &&
g.fnCeremonyApply != nil &&
g.fnCeremonySweep != nil
}
// =============================================================================
// MPC state-machine surface — four methods covering the substrate's apply /
// transition contract. Each one is a thin shim over the corresponding
// host launcher symbol; the device-side semantics live in
// ops/mpcvm/<X>/mpcvm_*.{cu,metal,wgsl,comp}.
//
// The same ceremony_apply launcher is used twice — CeremonyApply runs it
// with contribution_ops empty, ContributionApply runs it with ceremony_ops
// empty. This mirrors the orthogonal state-machine view (one kernel, two
// op streams) while keeping the C ABI surface tight.
// =============================================================================
// CeremonyApply applies ceremony begin/cancel ops to the ceremony table.
// Contribution slots are not touched (callers passing nil contribution_ops
// get the lean ceremony-admin dispatch).
//
// ceremonies is the open-addressed ceremony hash table (must be a power-of-2
// length on the device-side; the Go caller owns the buffer). The kernel
// mutates it in place; the round descriptor's CeremonyOpCount tells the
// kernel how many ops to consume from ceremonyOps.
//
// next_contribution_id_in is the substrate's monotonically increasing
// contribution-id counter at the start of the round; the kernel doesn't
// advance it on the ceremony-only path but the parameter is part of the
// shared launcher signature.
func (g *GPUBackend) CeremonyApply(
desc *GPUMPCVMRoundDescriptor,
ceremonyOps []GPUCeremonyOp,
ceremonies []GPUCeremony,
) (applied uint32, err error) {
if g == nil || g.handle == nil || g.fnCeremonyApply == nil {
return 0, ErrGPUNotAvailable
}
if desc == nil || len(ceremonies) == 0 {
return 0, fmt.Errorf("thresholdvm.CeremonyApply: nil desc or empty ceremonies")
}
var (
ceremonyApplied uint32
contributionApplied uint32
emptyContribOps [1]GPUContributionOp
emptyContributions [1]GPUContribution
)
descPtr := unsafe.Pointer(desc)
cerOpsPtr := unsafe.Pointer(&emptyContribOps[0])
if len(ceremonyOps) > 0 {
cerOpsPtr = unsafe.Pointer(&ceremonyOps[0])
}
cerPtr := unsafe.Pointer(&ceremonies[0])
conPtr := unsafe.Pointer(&emptyContributions[0])
rc := C.call_ceremony_apply(
g.fnCeremonyApply,
descPtr,
cerOpsPtr,
unsafe.Pointer(&emptyContribOps[0]), // contribution_ops: empty stream
cerPtr,
conPtr, // contributions: 1-slot placeholder (count=1)
unsafe.Pointer(&ceremonyApplied),
unsafe.Pointer(&contributionApplied),
C.uint32_t(len(ceremonies)),
C.uint32_t(1),
C.uint64_t(0),
nil,
)
runtime.KeepAlive(desc)
runtime.KeepAlive(ceremonyOps)
runtime.KeepAlive(ceremonies)
runtime.KeepAlive(emptyContribOps)
runtime.KeepAlive(emptyContributions)
if rc != 0 {
return 0, fmt.Errorf("thresholdvm.CeremonyApply: launcher rc=%d", int(rc))
}
return ceremonyApplied, nil
}
// KeyShareApply runs the per-slot fan-out sweep that advances ceremonies,
// finalizes keygens (assigning canonical share_ids), and times out expired
// ceremonies. Backed by lux_<X>_mpcvm_ceremony_sweep.
//
// On DKG finalize, fresh KeyShare slots are written into keyShares with the
// share_data_len matching the scheme (Frost=65, CGGMP21=65, Corona=256).
// next_share_id_in seeds the prefix-sum scheme that gives every finalized
// share a deterministic share_id.
func (g *GPUBackend) KeyShareApply(
desc *GPUMPCVMRoundDescriptor,
ceremonies []GPUCeremony,
keyShares []GPUKeyShare,
contributions []GPUContribution,
nextShareID uint64,
) (roundAdvance, finalized, failed uint32, err error) {
if g == nil || g.handle == nil || g.fnCeremonySweep == nil {
return 0, 0, 0, ErrGPUNotAvailable
}
if desc == nil || len(ceremonies) == 0 ||
len(keyShares) == 0 || len(contributions) == 0 {
return 0, 0, 0, fmt.Errorf("thresholdvm.KeyShareApply: nil desc or empty arena")
}
rc := C.call_ceremony_sweep(
g.fnCeremonySweep,
unsafe.Pointer(desc),
unsafe.Pointer(&ceremonies[0]),
unsafe.Pointer(&keyShares[0]),
unsafe.Pointer(&contributions[0]),
unsafe.Pointer(&roundAdvance),
unsafe.Pointer(&finalized),
unsafe.Pointer(&failed),
C.uint32_t(len(ceremonies)),
C.uint32_t(len(keyShares)),
C.uint32_t(len(contributions)),
C.uint64_t(nextShareID),
nil,
)
runtime.KeepAlive(desc)
runtime.KeepAlive(ceremonies)
runtime.KeepAlive(keyShares)
runtime.KeepAlive(contributions)
if rc != 0 {
return 0, 0, 0, fmt.Errorf("thresholdvm.KeyShareApply: launcher rc=%d", int(rc))
}
return roundAdvance, finalized, failed, nil
}
// ContributionApply applies contribution payloads to the contribution table.
// Ceremony slots are not touched (callers passing nil ceremonyOps get the
// lean contribution-only dispatch). Uses the same ceremony_apply kernel
// because the dedup-and-write path is the same on the device side; only
// the op stream differs.
func (g *GPUBackend) ContributionApply(
desc *GPUMPCVMRoundDescriptor,
contributionOps []GPUContributionOp,
ceremonies []GPUCeremony,
contributions []GPUContribution,
nextContributionID uint64,
) (applied uint32, err error) {
if g == nil || g.handle == nil || g.fnCeremonyApply == nil {
return 0, ErrGPUNotAvailable
}
if desc == nil || len(ceremonies) == 0 || len(contributions) == 0 {
return 0, fmt.Errorf("thresholdvm.ContributionApply: nil desc or empty arena")
}
var (
ceremonyApplied uint32
contributionApplied uint32
emptyCeremonyOps [1]GPUCeremonyOp
)
conOpsPtr := unsafe.Pointer(&emptyCeremonyOps[0]) // bound buffer placeholder
conActualOpsPtr := unsafe.Pointer(&emptyCeremonyOps[0])
if len(contributionOps) > 0 {
conActualOpsPtr = unsafe.Pointer(&contributionOps[0])
}
rc := C.call_ceremony_apply(
g.fnCeremonyApply,
unsafe.Pointer(desc),
conOpsPtr, // ceremony_ops: empty stream
conActualOpsPtr,
unsafe.Pointer(&ceremonies[0]),
unsafe.Pointer(&contributions[0]),
unsafe.Pointer(&ceremonyApplied),
unsafe.Pointer(&contributionApplied),
C.uint32_t(len(ceremonies)),
C.uint32_t(len(contributions)),
C.uint64_t(nextContributionID),
nil,
)
runtime.KeepAlive(desc)
runtime.KeepAlive(emptyCeremonyOps)
runtime.KeepAlive(contributionOps)
runtime.KeepAlive(ceremonies)
runtime.KeepAlive(contributions)
if rc != 0 {
return 0, fmt.Errorf("thresholdvm.ContributionApply: launcher rc=%d", int(rc))
}
return contributionApplied, nil
}
// MPCTransition runs the per-leaf keccak pass and the canonical-order fold
// pass, producing the round's MPCVMTransitionResult and advancing the
// substrate state. This is the composition of compute_leaves and
// compose_root — one substrate state transition per call.
//
// Returns the result envelope written by compose_root. The substrate state
// in `state` is also updated in place (cur_epoch, now_ns, counts, all four
// roots).
func (g *GPUBackend) MPCTransition(
desc *GPUMPCVMRoundDescriptor,
ceremonies []GPUCeremony,
keyShares []GPUKeyShare,
contributions []GPUContribution,
state *GPUMPCVMState,
) (*GPUMPCVMTransitionResult, error) {
if g == nil || g.handle == nil {
return nil, ErrGPUNotAvailable
}
if g.fnComputeLeaves == nil || g.fnComposeRoot == nil {
return nil, fmt.Errorf("thresholdvm.MPCTransition: %w (compute_leaves or compose_root missing)",
ErrGPUNotAvailable)
}
if desc == nil || state == nil ||
len(ceremonies) == 0 || len(keyShares) == 0 || len(contributions) == 0 {
return nil, fmt.Errorf("thresholdvm.MPCTransition: nil desc/state or empty arena")
}
// Scratch buffers consumed by compute_leaves and feeding compose_root.
// All Go-allocated, all live for the duration of both calls — pinned by
// being on the local stack (escape-analysis-promoted to heap if needed).
ceremonyLeaves := make([]byte, len(ceremonies)*32)
shareLeaves := make([]byte, len(keyShares)*32)
contributionLeaves := make([]byte, len(contributions)*32)
ceremonyMask := make([]byte, len(ceremonies))
shareMask := make([]byte, len(keyShares))
contributionMask := make([]byte, len(contributions))
var activeCount, finalizedCount, failedCount, shareCount uint32
var result GPUMPCVMTransitionResult
rc := C.call_compute_leaves(
g.fnComputeLeaves,
unsafe.Pointer(&ceremonies[0]),
unsafe.Pointer(&keyShares[0]),
unsafe.Pointer(&contributions[0]),
unsafe.Pointer(&ceremonyLeaves[0]),
unsafe.Pointer(&shareLeaves[0]),
unsafe.Pointer(&contributionLeaves[0]),
unsafe.Pointer(&activeCount),
unsafe.Pointer(&finalizedCount),
unsafe.Pointer(&failedCount),
unsafe.Pointer(&shareCount),
unsafe.Pointer(&ceremonyMask[0]),
unsafe.Pointer(&shareMask[0]),
unsafe.Pointer(&contributionMask[0]),
C.uint32_t(len(ceremonies)),
C.uint32_t(len(keyShares)),
C.uint32_t(len(contributions)),
nil,
)
if rc != 0 {
return nil, fmt.Errorf("thresholdvm.MPCTransition compute_leaves: launcher rc=%d", int(rc))
}
rc = C.call_compose_root(
g.fnComposeRoot,
unsafe.Pointer(desc),
unsafe.Pointer(&ceremonyLeaves[0]),
unsafe.Pointer(&shareLeaves[0]),
unsafe.Pointer(&contributionLeaves[0]),
unsafe.Pointer(&ceremonyMask[0]),
unsafe.Pointer(&shareMask[0]),
unsafe.Pointer(&contributionMask[0]),
unsafe.Pointer(&activeCount),
unsafe.Pointer(&finalizedCount),
unsafe.Pointer(&failedCount),
unsafe.Pointer(&shareCount),
unsafe.Pointer(state),
unsafe.Pointer(&result),
C.uint32_t(len(ceremonies)),
C.uint32_t(len(keyShares)),
C.uint32_t(len(contributions)),
nil,
)
runtime.KeepAlive(desc)
runtime.KeepAlive(ceremonies)
runtime.KeepAlive(keyShares)
runtime.KeepAlive(contributions)
runtime.KeepAlive(state)
runtime.KeepAlive(ceremonyLeaves)
runtime.KeepAlive(shareLeaves)
runtime.KeepAlive(contributionLeaves)
runtime.KeepAlive(ceremonyMask)
runtime.KeepAlive(shareMask)
runtime.KeepAlive(contributionMask)
if rc != 0 {
return nil, fmt.Errorf("thresholdvm.MPCTransition compose_root: launcher rc=%d", int(rc))
}
return &result, nil
}
// =============================================================================
// Plugin loader — dlopen one candidate path, dlsym the four symbol pairs,
// fail soft if the plugin is missing or any required symbol is absent.
// Soft-fail returns nil; callers then see Backend() == nil and route to CPU.
// =============================================================================
// tryLoadPlugin attempts to dlopen the candidate filenames in order and
// resolve the four expected symbols using the given backend prefix. Returns
// non-nil only when dlopen succeeded AND ceremony_apply + ceremony_sweep
// both resolved (the minimum useful surface). compute_leaves and
// compose_root are optional at this stage — MPCTransition will surface
// ErrGPUNotAvailable when either is missing.
func tryLoadPlugin(kind GPUBackendKind, candidates ...string) *GPUBackend {
prefix := kind.String()
if prefix == "none" {
return nil
}
var (
ca *C.char
cb *C.char
cc *C.char
)
if len(candidates) > 0 && candidates[0] != "" {
ca = C.CString(candidates[0])
defer C.free(unsafe.Pointer(ca))
}
if len(candidates) > 1 && candidates[1] != "" {
cb = C.CString(candidates[1])
defer C.free(unsafe.Pointer(cb))
}
if len(candidates) > 2 && candidates[2] != "" {
cc = C.CString(candidates[2])
defer C.free(unsafe.Pointer(cc))
}
h := C.lux_dlopen_first(ca, cb, cc)
if h == nil {
return nil
}
resolve := func(name string) unsafe.Pointer {
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
return unsafe.Pointer(C.lux_dlsym(h, cname))
}
b := &GPUBackend{
Kind: kind,
handle: unsafe.Pointer(h),
fnCeremonyApply: resolve(fmt.Sprintf("lux_%s_mpcvm_ceremony_apply", prefix)),
fnCeremonySweep: resolve(fmt.Sprintf("lux_%s_mpcvm_ceremony_sweep", prefix)),
fnComputeLeaves: resolve(fmt.Sprintf("lux_%s_mpcvm_compute_leaves", prefix)),
fnComposeRoot: resolve(fmt.Sprintf("lux_%s_mpcvm_compose_root", prefix)),
}
if b.fnCeremonyApply == nil || b.fnCeremonySweep == nil {
// Required symbols missing — close the handle and return nil so the
// probe falls through to the next backend.
_ = C.dlclose(h)
return nil
}
// Pick a resolved path string from the first non-empty candidate that
// dlopen could have used. We don't get the resolved name back from
// dlopen on every platform, so this is best-effort diagnostic.
for _, c := range candidates {
if c != "" {
b.Path = c
break
}
}
return b
}
// =============================================================================
// Env override — LUX_THRESHOLDVM_GPU_BACKEND=cuda|hip|metal|vulkan|webgpu|none
// pins the backend choice. Useful for tests + ops to force a specific path.
// =============================================================================
func envBackendOverride() (GPUBackendKind, bool) {
v := os.Getenv("LUX_THRESHOLDVM_GPU_BACKEND")
switch v {
case "cuda":
return GPUBackendCUDA, true
case "hip":
return GPUBackendHIP, true
case "metal":
return GPUBackendMetal, true
case "vulkan":
return GPUBackendVulkan, true
case "webgpu":
return GPUBackendWebGPU, true
case "none":
return GPUBackendNone, true
default:
return GPUBackendNone, false
}
}
+194
View File
@@ -0,0 +1,194 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !cgo
package thresholdvm
import "errors"
// ErrGPUNotAvailable is returned by GPUBackend methods when the build was
// produced without CGo. The thresholdvm GPU substrate is a runtime overlay
// that requires dlopen()/dlsym() — under !cgo there is no way to reach the
// host launchers, so every method on GPUBackend short-circuits to this
// error and callers fall back to the CPU reference (the Go state machine
// in protocol/, factory.go, executor.go — unchanged).
var ErrGPUNotAvailable = errors.New("thresholdvm: GPU backend not available (built without CGo)")
// GPUBackendKind mirrors the cgo variant so the package surface is
// identical regardless of build mode. Under nocgo every value is None.
type GPUBackendKind uint8
const (
GPUBackendNone GPUBackendKind = 0
GPUBackendCUDA GPUBackendKind = 1
GPUBackendHIP GPUBackendKind = 2
GPUBackendMetal GPUBackendKind = 3
GPUBackendVulkan GPUBackendKind = 4
GPUBackendWebGPU GPUBackendKind = 5
)
func (k GPUBackendKind) String() string { return "none" }
// =============================================================================
// Wire structs — same fields as the cgo variant so external callers that
// build with -tags '!cgo' still see the same package API. The sizes are
// not asserted here because no kernel ever runs against them.
// =============================================================================
type GPUCeremony struct {
CeremonyID uint64
StartedAtNs uint64
DeadlineNs uint64
ParticipantsBitmap uint64
Kind uint32
Round uint32
Threshold uint32
TotalParticipants uint32
Status uint32
ContributionCount uint32
Subject [32]byte
CeremonySeed [32]byte
_ [8]byte
}
type GPUKeyShare struct {
ShareID uint64
CeremonyID uint64
HolderAddr uint64
Scheme uint32
HolderIndex uint32
ShareDataLen uint32
Occupied uint32
ShareData [320]byte
Pad0 uint64
}
type GPUContribution struct {
ContributionID uint64
CeremonyID uint64
HolderAddr uint64
Round uint32
HolderIndex uint32
PayloadLen uint32
Status uint32
Payload [384]byte
Pad0 uint64
}
type GPUMPCVMState struct {
CurrentEpoch uint64
NowNs uint64
ActiveCeremonyCount uint32
FinalizedCeremonyCount uint32
FailedCeremonyCount uint32
KeyShareCount uint32
CeremonyRoot [32]byte
KeyShareRoot [32]byte
ContributionRoot [32]byte
MPCVMStateRoot [32]byte
}
type GPUMPCVMRoundDescriptor struct {
ChainID uint64
Round uint64
TimestampNs uint64
Epoch uint64
Mode uint32
CeremonyOpCount uint32
ContributionOpCount uint32
ClosingFlag uint32
Pad0 uint32
Pad1 uint32
Pad2 uint64
ParentStateRoot [32]byte
}
type GPUCeremonyOp struct {
CeremonyID uint64
DeadlineNs uint64
Kind uint32
CeremonyKind uint32
Threshold uint32
TotalParticipants uint32
Subject [32]byte
CeremonySeed [32]byte
}
type GPUContributionOp struct {
CeremonyID uint64
HolderAddr uint64
Round uint32
HolderIndex uint32
PayloadLen uint32
Pad0 uint32
Payload [384]byte
}
type GPUMPCVMTransitionResult struct {
Status uint32
CeremonyApplyCount uint32
ContributionApplyCount uint32
FinalizedThisRound uint32
FailedThisRound uint32
ActiveCeremonyCount uint32
KeyShareCount uint32
RoundAdvanceCount uint32
Epoch uint64
NowNs uint64
CeremonyRoot [32]byte
KeyShareRoot [32]byte
ContributionRoot [32]byte
MPCVMStateRoot [32]byte
}
// GPUBackend is the no-op nocgo placeholder. Every method returns
// ErrGPUNotAvailable; IsAvailable() returns false.
type GPUBackend struct {
Kind GPUBackendKind
Path string
}
// Backend always returns nil under !cgo — the bridge has no plugin to load.
func Backend() *GPUBackend { return nil }
// IsAvailable reports false for every GPUBackend under !cgo.
func (g *GPUBackend) IsAvailable() bool { return false }
func (g *GPUBackend) CeremonyApply(
_ *GPUMPCVMRoundDescriptor,
_ []GPUCeremonyOp,
_ []GPUCeremony,
) (uint32, error) {
return 0, ErrGPUNotAvailable
}
func (g *GPUBackend) KeyShareApply(
_ *GPUMPCVMRoundDescriptor,
_ []GPUCeremony,
_ []GPUKeyShare,
_ []GPUContribution,
_ uint64,
) (uint32, uint32, uint32, error) {
return 0, 0, 0, ErrGPUNotAvailable
}
func (g *GPUBackend) ContributionApply(
_ *GPUMPCVMRoundDescriptor,
_ []GPUContributionOp,
_ []GPUCeremony,
_ []GPUContribution,
_ uint64,
) (uint32, error) {
return 0, ErrGPUNotAvailable
}
func (g *GPUBackend) MPCTransition(
_ *GPUMPCVMRoundDescriptor,
_ []GPUCeremony,
_ []GPUKeyShare,
_ []GPUContribution,
_ *GPUMPCVMState,
) (*GPUMPCVMTransitionResult, error) {
return nil, ErrGPUNotAvailable
}
+154
View File
@@ -0,0 +1,154 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thresholdvm
import (
"errors"
"testing"
"unsafe"
)
// TestGPULayoutSizes pins the on-wire struct sizes to the device-side
// __align__(16) values declared in
// ~/work/lux-private/gpu-kernels/ops/mpcvm/cuda/mpcvm_kernels_common.cuh.
// Any drift would produce silently-wrong kernel results — the same check
// runs inside assertSizes() at init() under cgo; this test pins it for
// nocgo builds AND lets `go test` surface the mismatch immediately
// instead of waiting for a process start to panic.
func TestGPULayoutSizes(t *testing.T) {
cases := []struct {
name string
want uintptr
got uintptr
}{
{"GPUCeremony", 128, unsafe.Sizeof(GPUCeremony{})},
{"GPUKeyShare", 368, unsafe.Sizeof(GPUKeyShare{})},
{"GPUContribution", 432, unsafe.Sizeof(GPUContribution{})},
{"GPUMPCVMState", 160, unsafe.Sizeof(GPUMPCVMState{})},
{"GPUMPCVMRoundDescriptor", 96, unsafe.Sizeof(GPUMPCVMRoundDescriptor{})},
{"GPUCeremonyOp", 96, unsafe.Sizeof(GPUCeremonyOp{})},
{"GPUContributionOp", 416, unsafe.Sizeof(GPUContributionOp{})},
{"GPUMPCVMTransitionResult", 176, unsafe.Sizeof(GPUMPCVMTransitionResult{})},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s: sizeof=%d want=%d (device-side mpcvm_kernels_common.cuh __align__(16))",
c.name, c.got, c.want)
}
}
}
// TestGPUBackendKindString round-trips the resolved plugin kind to the
// launcher symbol prefix used by tryLoadPlugin(). The cgo flavor returns
// the prefix used in lux_<X>_mpcvm_*; the !cgo flavor returns "none" for
// every kind because no dlopen ever happens. Both branches must surface
// GPUBackendNone as "none" so callers can `if kind.String() == "none"`
// regardless of build mode.
func TestGPUBackendKindString(t *testing.T) {
if got := GPUBackendNone.String(); got != "none" {
t.Errorf("GPUBackendNone.String()=%q want=%q", got, "none")
}
// Under cgo the named backends round-trip to their prefix. Under !cgo
// they all collapse to "none". Cover the cgo expectation only when we
// see Backend() != nil OR see GPUBackendCUDA round-tripping correctly —
// otherwise we're in the nocgo build and the noop contract applies.
cudaName := GPUBackendCUDA.String()
if cudaName != "cuda" && cudaName != "none" {
t.Errorf("GPUBackendCUDA.String()=%q want one of {cuda, none}", cudaName)
}
if cudaName == "cuda" {
// cgo build — pin all the other names too.
cases := []struct {
k GPUBackendKind
want string
}{
{GPUBackendHIP, "hip"},
{GPUBackendMetal, "metal"},
{GPUBackendVulkan, "vulkan"},
{GPUBackendWebGPU, "webgpu"},
}
for _, c := range cases {
if got := c.k.String(); got != c.want {
t.Errorf("kind=%d string=%q want=%q", c.k, got, c.want)
}
}
}
}
// TestGPUBridgeZeroFixture is the dlopen round-trip required by the task:
// resolve the best-available backend, call lux_<best>_mpcvm_ceremony_apply
// with a zero fixture, assert no error. The two acceptable outcomes are:
//
// 1. The plugin is dlopen'd (e.g. on a dev box with the metal_backend
// dylib in lux-private/gpu-kernels/build/metal_backend/). The launcher
// runs against the zero fixture and returns rc=0 (zero ops applied).
//
// 2. The plugin is absent. Backend() returns nil; we get ErrGPUNotAvailable.
//
// Both outcomes count as a passing round-trip — the bridge correctly
// surfaces the GPU state through the public Go API. The test only fails
// on a third outcome: a partially-resolved plugin that returns an
// unexpected error rc.
func TestGPUBridgeZeroFixture(t *testing.T) {
b := Backend()
if b == nil || !b.IsAvailable() {
t.Logf("no GPU plugin resolved (expected on CI without a dylib in LUX_PRIVATE_GPU_KERNELS_DIR/build/)")
// Re-prove the error contract by calling through the nil receiver:
// the GPUBackend method set on nil must return ErrGPUNotAvailable
// rather than panicking. This is the substrate contract.
_, err := (*GPUBackend)(nil).CeremonyApply(nil, nil, nil)
if !errors.Is(err, ErrGPUNotAvailable) {
t.Fatalf("nil receiver: want ErrGPUNotAvailable, got %v", err)
}
return
}
t.Logf("resolved GPU backend: kind=%s path=%s", b.Kind, b.Path)
// Zero fixture: 1 empty ceremony slot, no ceremony ops, no contribution
// ops. The kernel walks zero ops and returns applied=0, rc=0. Any
// non-zero rc is a real launcher failure — fail the test.
desc := &GPUMPCVMRoundDescriptor{
ChainID: 0xDEADBEEF,
Round: 1,
TimestampNs: 1700000000_000000000,
Epoch: 1,
Mode: 0,
}
// The ceremony arena MUST be a power-of-2 length (device-side open
// addressing). The smallest legal arena is 1 slot.
ceremonies := make([]GPUCeremony, 1)
applied, err := b.CeremonyApply(desc, nil, ceremonies)
if err != nil {
t.Fatalf("CeremonyApply(zero): %v", err)
}
if applied != 0 {
t.Errorf("CeremonyApply(zero): applied=%d want=0 (no ops in input)", applied)
}
}
// TestGPUBridgeNoCgoStub verifies that the !cgo build flavor surfaces the
// same Go API surface as cgo, with every method returning ErrGPUNotAvailable
// and IsAvailable()==false. Under cgo this test runs the cgo surface (which
// also returns ErrGPUNotAvailable when no plugin is loaded), so the contract
// is consistent across build flavors.
func TestGPUBridgeNoCgoStub(t *testing.T) {
// Constructing a zero GPUBackend mimics the nocgo Backend() == nil
// path. Every method must error out without panicking.
var b GPUBackend
if b.IsAvailable() {
t.Fatal("zero GPUBackend.IsAvailable() must be false")
}
if _, err := b.CeremonyApply(nil, nil, nil); !errors.Is(err, ErrGPUNotAvailable) {
t.Errorf("CeremonyApply: want ErrGPUNotAvailable, got %v", err)
}
if _, _, _, err := b.KeyShareApply(nil, nil, nil, nil, 0); !errors.Is(err, ErrGPUNotAvailable) {
t.Errorf("KeyShareApply: want ErrGPUNotAvailable, got %v", err)
}
if _, err := b.ContributionApply(nil, nil, nil, nil, 0); !errors.Is(err, ErrGPUNotAvailable) {
t.Errorf("ContributionApply: want ErrGPUNotAvailable, got %v", err)
}
if _, err := b.MPCTransition(nil, nil, nil, nil, nil); !errors.Is(err, ErrGPUNotAvailable) {
t.Errorf("MPCTransition: want ErrGPUNotAvailable, got %v", err)
}
}