quantumvm: wire GPU plugin substrate via ABI v14 vtbl dlopen+dlsym

Adds four Go files to chains/quantumvm so the Q-Chain VM can call into
the lux-private/gpu-kernels plugin's batched ML-DSA verify, ML-DSA sign,
and SLH-DSA verify slots without a build-time pkg-config dependency:

* backend.go            cross-build types: Backend enum, GPUBackend
                        interface, MLDSAMode + MLDSA65* size constants,
                        SLHDSAVariant enum + per-variant pk/sig widths
                        (FIPS 204 / FIPS 205), ErrGPUNotAvailable,
                        AutoBackend(). Build-tag-free so vm.go can
                        reference the surface regardless of CGO_ENABLED.
* quantumvm_gpu.go      cgo dlopen probe at init() in order
   (cgo)                cuda -> hip -> metal -> vulkan -> webgpu,
                        resolving lux_gpu_backend_init and verifying
                        the ABI v14 cookies (abi_version + vtbl_size)
                        plus the load-bearing op_mldsa_verify_batch
                        slot before opening a LuxBackendContext. Three
                        vtbl-slot wrappers dispatch through C
                        trampolines into op_mldsa_verify_batch,
                        op_mldsa_sign_batch, and the SHAKE-128f /
                        SHAKE-192f variants of op_slhdsa_verify_batch.
* quantumvm_gpu_nocgo.go symmetric noGPUBackend stub returning
                        ErrGPUNotAvailable on every method so the
                        package surface is identical under !cgo.
* quantumvm_gpu_test.go round-trip test: dlopens the configured
                        plugin (LUX_GPU_PLUGIN_DIR override or default
                        loader path), generates an ML-DSA-65 keypair +
                        signs a 64-byte digest via circl, then submits
                        the triple through MLDSAVerifyBatch and asserts
                        results[0] == true. Cleanly SKIPs when no
                        plugin loaded or when the backend returns
                        NOT_SUPPORTED for op_mldsa_verify_batch (e.g.
                        plugins built without LUX_GPU_LINK_CRYPTO).

The probe is header-only: cgo CFLAGS points at
${SRCDIR}/../../../lux-private/gpu-kernels/include for the v14 vtbl
layout, but the .dylib / .so is dlopen'd at runtime - no pkg-config,
no static link, no luxcpp/install dependency. Existing quantumvm
vm.go, quantum/signer.go, and quantumvm/quasar.go are untouched;
consumers opt in explicitly when ready. Both 'go build ./quantumvm/...'
and 'CGO_ENABLED=1 go build ./quantumvm/...' are green.

Test output with LUX_GPU_PLUGIN_DIR pointing at a freshly-built
libluxgpu_backend_vulkan.dylib:
  --- PASS: TestAutoBackend_Probes (0.00s)          [backend=vulkan]
  --- PASS: TestMLDSAVerifyBatch_RoundTrip (5.56s)  [backend=vulkan,
            op_mldsa_verify_batch honest verify -> true]
  --- PASS: TestMLDSAVerifyBatch_LengthMismatch (0.00s)
  --- PASS: TestMLDSAVerifyBatch_NonMLDSA65Skips  (0.00s)
This commit is contained in:
Hanzo AI
2026-06-05 14:59:54 -07:00
parent fa58e4a888
commit ec4f079c45
4 changed files with 1542 additions and 0 deletions
+343
View File
@@ -0,0 +1,343 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quantumvm
// Q-Chain GPU substrate — host-side ABI v14 plugin bridge for batched
// post-quantum signature verification + signing.
//
// The kernels live in ~/work/lux-private/gpu-kernels under
// ops/crypto/{mldsa,slhdsa}/<backend>/ and are shipped inside the
// libluxgpu_backend_<x>.{so,dylib} plugin shared libraries. The Go side
// dlopens whichever plugin is on disk at process start, populates the
// ABI v14 backend descriptor via the `lux_gpu_backend_init` entry point
// (see lux-private/gpu-kernels/include/lux/gpu/backend_plugin.h), and
// invokes the op_mldsa_verify_batch / op_mldsa_sign_batch /
// op_slhdsa_verify_batch slots from the resulting vtbl.
//
// Decomplecting from cevm/cevm_cgo.go: cevm links its kernel bundle via
// pkg-config (lux-cevm.pc → -levm -levm-gpu …) and reaches the C ABI
// through extern "C" gpu_* helpers. Q-Chain takes the opposite tack —
// no pkg-config, no static link, no header-bound C ABI helpers. The
// plugin is OPTIONAL by design: when no libluxgpu_backend_*.{so,dylib}
// is on the dlopen search path, AutoBackend() returns BackendNone and
// every GPUBackend method returns ErrGPUNotAvailable so the existing
// CPU verify path stays unchanged.
//
// Pattern (mirrors evm/cevm + evm/backend_cgo.go split):
//
// backend.go — shared types: Backend enum, GPUBackend
// interface, ErrGPUNotAvailable, active
// backend atomic + AutoBackend(). Build-
// tag-free; the runtime probe lives in
// the cgo/nocgo files below.
// quantumvm_gpu.go cgo — dlopen + dlsym of lux_gpu_backend_init,
// vtbl slot invocations.
// quantumvm_gpu_nocgo.go !cgo — stub returning ErrGPUNotAvailable.
//
// This file is build-tag-free so vm.go can reference Backend, GPUBackend,
// AutoBackend(), and ErrGPUNotAvailable regardless of CGO_ENABLED. The
// implementation behind ActiveGPUBackend() switches based on the build
// tag (real plugin handle vs. stub returning ErrGPUNotAvailable).
import (
"errors"
"fmt"
"sync/atomic"
)
// Backend identifies which GPU plugin satisfied the runtime dlopen probe.
//
// Probe order is fixed by the spec: cuda → hip → metal → vulkan → webgpu.
// The first plugin that resolves the `lux_gpu_backend_init` entry point and
// reports a v14 vtbl with the three Q-Chain slots (op_mldsa_verify_batch,
// op_mldsa_sign_batch, op_slhdsa_verify_batch) wins; remaining probes are
// skipped. Each plugin is fully self-contained — there is no fallback chain
// once one is chosen (this keeps backend selection deterministic across
// reboots, matching consensus-safety expectations).
type Backend uint8
const (
// BackendNone means no GPU plugin is loaded — every GPUBackend method
// returns ErrGPUNotAvailable. This is the value reported by
// AutoBackend() under !cgo, and under cgo when no
// libluxgpu_backend_*.{so,dylib} was findable.
BackendNone Backend = 0
// BackendCUDA selects libluxgpu_backend_cuda.so (NVIDIA, Linux/Windows).
BackendCUDA Backend = 1
// BackendHIP selects libluxgpu_backend_hip.so (AMD ROCm, Linux/Windows).
BackendHIP Backend = 2
// BackendMetal selects libluxgpu_backend_metal.dylib (Apple, darwin).
BackendMetal Backend = 3
// BackendVulkan selects libluxgpu_backend_vulkan.{so,dylib} (portable).
BackendVulkan Backend = 4
// BackendWebGPU selects libluxgpu_backend_webgpu.{so,dylib} (portable).
BackendWebGPU Backend = 5
)
// String returns the canonical lowercase name of the backend. The same
// name appears in the plugin DSO filename (libluxgpu_backend_<name>.*)
// and in the lux_gpu_backend_desc.backend_name field reported by the
// loaded plugin.
func (b Backend) String() string {
switch b {
case BackendNone:
return "none"
case BackendCUDA:
return "cuda"
case BackendHIP:
return "hip"
case BackendMetal:
return "metal"
case BackendVulkan:
return "vulkan"
case BackendWebGPU:
return "webgpu"
default:
return fmt.Sprintf("unknown(%d)", uint8(b))
}
}
// ErrGPUNotAvailable is returned by every GPUBackend method when no plugin
// is loaded — either because the binary was built without CGo, no
// libluxgpu_backend_*.{so,dylib} was findable on the dlopen search path,
// the loaded plugin reported a non-v14 ABI version, or its vtbl was
// missing one of the three Q-Chain slots.
//
// The error is sentinel-comparable via errors.Is so callers can route
// the CPU verify path cleanly:
//
// if errors.Is(err, quantumvm.ErrGPUNotAvailable) {
// return cpuVerify(pk, msg, sig)
// }
var ErrGPUNotAvailable = errors.New("quantumvm: GPU backend not available")
// MLDSAMode selects which ML-DSA security parameter set the batch
// targets. The ABI v14 vtbl currently exposes only ML-DSA-65 (mode 3,
// FIPS 204 Dilithium3) — modes 44 and 87 will land at their own vtbl
// slots in a future ABI bump (see backend_plugin.h v14 block). Callers
// pass the mode through so the wrapper can validate batch byte widths
// at the boundary, even though only MLDSA65 round-trips through the
// plugin today.
type MLDSAMode uint8
const (
// MLDSAMode44 is FIPS 204 Dilithium2. Not yet wired through the v14
// vtbl — passing this to a batch method returns ErrGPUNotAvailable
// (caller falls through to the CPU path).
MLDSAMode44 MLDSAMode = 1
// MLDSAMode65 is FIPS 204 Dilithium3 — the canonical Q-Chain mode.
// pk=1952 B, sig=3309 B (max), sk=4032 B.
MLDSAMode65 MLDSAMode = 2
// MLDSAMode87 is FIPS 204 Dilithium5. Not yet wired through the v14
// vtbl — passing this returns ErrGPUNotAvailable.
MLDSAMode87 MLDSAMode = 3
)
// ML-DSA-65 byte sizes per FIPS 204 §4. Pinned constants — the plugin
// rejects mismatched sk_stride at the boundary, so we surface them up
// front to keep the call sites unambiguous.
const (
MLDSA65PublicKeySize = 1952
MLDSA65SecretKeySize = 4032
MLDSA65SignatureSize = 3309
)
// SLHDSAVariant enumerates the 12 FIPS 205 SLH-DSA parameter sets. Each
// variant has its own (pk, sig) byte width — surfaced via PublicKeySize()
// and SignatureSize() so callers can build the batch arrays correctly.
//
// At ABI v14 only the SHAKE-128f and SHAKE-192f verify slots are wired
// (op_slhdsa_verify_batch and op_slhdsa_verify_batch_shake192f). The
// remaining 10 variants land at their own vtbl slots in future ABI
// bumps; passing them today returns ErrGPUNotAvailable.
type SLHDSAVariant uint8
const (
SLHDSAShake128f SLHDSAVariant = iota
SLHDSAShake128s
SLHDSAShake192f
SLHDSAShake192s
SLHDSAShake256f
SLHDSAShake256s
SLHDSASha2128f
SLHDSASha2128s
SLHDSASha2192f
SLHDSASha2192s
SLHDSASha2256f
SLHDSASha2256s
)
// String returns the FIPS 205 canonical name of the variant.
func (v SLHDSAVariant) String() string {
switch v {
case SLHDSAShake128f:
return "shake-128f"
case SLHDSAShake128s:
return "shake-128s"
case SLHDSAShake192f:
return "shake-192f"
case SLHDSAShake192s:
return "shake-192s"
case SLHDSAShake256f:
return "shake-256f"
case SLHDSAShake256s:
return "shake-256s"
case SLHDSASha2128f:
return "sha2-128f"
case SLHDSASha2128s:
return "sha2-128s"
case SLHDSASha2192f:
return "sha2-192f"
case SLHDSASha2192s:
return "sha2-192s"
case SLHDSASha2256f:
return "sha2-256f"
case SLHDSASha2256s:
return "sha2-256s"
default:
return fmt.Sprintf("slhdsa(%d)", uint8(v))
}
}
// PublicKeySize returns the FIPS 205 byte width of an SLH-DSA public key
// for this variant. {128,192,256}{f,s} all share the same width per
// security level (32 / 48 / 64 bytes).
func (v SLHDSAVariant) PublicKeySize() int {
switch v {
case SLHDSAShake128f, SLHDSAShake128s, SLHDSASha2128f, SLHDSASha2128s:
return 32
case SLHDSAShake192f, SLHDSAShake192s, SLHDSASha2192f, SLHDSASha2192s:
return 48
case SLHDSAShake256f, SLHDSAShake256s, SLHDSASha2256f, SLHDSASha2256s:
return 64
default:
return 0
}
}
// SignatureSize returns the FIPS 205 byte width of an SLH-DSA signature
// for this variant. The 12 widths are fixed by parameter set; see the
// FIPS 205 standard §11 width table.
func (v SLHDSAVariant) SignatureSize() int {
switch v {
case SLHDSAShake128f, SLHDSASha2128f:
return 17088
case SLHDSAShake128s, SLHDSASha2128s:
return 7856
case SLHDSAShake192f, SLHDSASha2192f:
return 35664
case SLHDSAShake192s, SLHDSASha2192s:
return 16224
case SLHDSAShake256f, SLHDSASha2256f:
return 49856
case SLHDSAShake256s, SLHDSASha2256s:
return 29792
default:
return 0
}
}
// GPUBackend is the narrow Q-Chain surface that vm.go's batch verify /
// sign paths can opt into. Three 1:1 mappings to the ABI v14 vtbl slots,
// plus Backend() / Close() lifecycle.
//
// Buffer ownership: callers own every slice / struct passed in. The vtbl
// slot does H2D / D2H internally. On return every output slice has been
// overwritten with the kernel's result; the caller can read immediately,
// no further sync needed.
//
// Composition: the three batch methods are orthogonal — a "round" that
// verifies a mixed batch of ML-DSA + SLH-DSA stamps composes them in
// the round applier. We do NOT try to express a "verify all PQ sigs"
// supercall here; that would couple the round-level policy to the
// substrate, and the substrate intentionally only exposes primitives.
type GPUBackend interface {
// MLDSAVerifyBatch verifies a batch of ML-DSA signatures. mode pins
// the FIPS 204 parameter set; only MLDSAMode65 is wired through the
// v14 vtbl (modes 44/87 return ErrGPUNotAvailable). pubkeys[i],
// messages[i], and signatures[i] supply the per-element inputs;
// msgLens[i] is the byte length of messages[i] (NULL slice means
// every message is exactly msgWidthHint bytes — see the
// op_mldsa_verify_batch comment in backend_plugin.h for the
// uniform-batch convenience contract). results[i] is set to true
// iff verification succeeds.
//
// The slices must all have the same length; mismatched lengths are
// caught at the boundary and returned as an error (no UB into C).
MLDSAVerifyBatch(
mode MLDSAMode,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
msgWidthHint uint32,
signatures [][]byte,
results []bool,
) error
// MLDSASignBatch signs a batch of messages with packed ML-DSA secret
// keys. mode pins the FIPS 204 parameter set; only MLDSAMode65 is
// wired through the v14 vtbl. skeys is the contiguous pool of
// per-element packed secret keys (each MLDSA65SecretKeySize = 4032
// bytes); the wrapper feeds the pool through cudaMemcpy2D directly
// in one shot. msgs is the contiguous pool of per-element message
// payloads (msgLens[i] bytes from offset sum(msgLens[0..i-1])).
// sigsOut receives count × MLDSA65SignatureSize bytes; sigLensOut
// returns the per-element actual signature length (3309 on accept,
// 0 on kappa-cap reject — see backend_plugin.h v14 block).
MLDSASignBatch(
mode MLDSAMode,
skeys []byte,
msgs []byte,
msgLens []int,
msgWidthHint uint32,
count int,
sigsOut []byte,
sigLensOut []uint32,
) error
// SLHDSAVerifyBatch verifies a batch of SLH-DSA signatures. variant
// pins the FIPS 205 parameter set; only SHAKE-128f and SHAKE-192f
// are wired through the v14 vtbl (the other 10 variants return
// ErrGPUNotAvailable). pubkeys[i], messages[i], and signatures[i]
// supply the per-element inputs at the FIPS 205 widths reported by
// variant.PublicKeySize() / variant.SignatureSize(); msgLens[i] is
// the byte length of messages[i] (any value in [0, INT32_MAX-2]).
// results[i] is set to true iff verification succeeds.
SLHDSAVerifyBatch(
variant SLHDSAVariant,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
signatures [][]byte,
results []bool,
) error
// Backend reports which plugin is currently loaded.
Backend() Backend
// Close releases the dlopen handle and the LuxBackendContext. Safe
// on a nil receiver and idempotent.
Close() error
}
// activeBackend is set by the init() in quantumvm_gpu.go (cgo) or
// quantumvm_gpu_nocgo.go (!cgo). Read via AutoBackend(). Stored as an
// atomic uint32 so concurrent reads from vm.go's batch paths don't race
// with the init() store.
var activeBackend atomic.Uint32
// AutoBackend returns the GPU plugin chosen by the dlopen probe at process
// start. BackendNone means no plugin is loaded — callers should route to
// the CPU verify path. The probe runs exactly once at init time; this
// getter is one atomic load and safe to call from any goroutine.
func AutoBackend() Backend {
return Backend(uint8(activeBackend.Load()))
}
// setActiveBackend records the probe result. Called by init() in the
// cgo / nocgo files. Not exported — there is one and only one way to
// load the plugin (the init probe at process start).
func setActiveBackend(b Backend) {
activeBackend.Store(uint32(uint8(b)))
}
+925
View File
@@ -0,0 +1,925 @@
//go:build cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package quantumvm GPU backend — runtime-loaded plugin bridge via the
// ABI v14 vtbl entry point.
//
// Unlike cevm/cevm_cgo.go (which links libevm + libevm-gpu via pkg-config
// and reaches the C ABI through extern "C" gpu_* helpers), Q-Chain
// resolves its GPU substrate at PROCESS START via dlopen / dlsym against
// the lux-gpu-kernels plugin DSO and reaches the per-op kernel through
// function pointers inside a vtbl populated by the plugin's
// `lux_gpu_backend_init` entry point. This keeps the chains module
// compilable without lux-private/gpu-kernels present in the build tree —
// the plugin is fully optional. When no libluxgpu_backend_*.{so,dylib}
// is findable on the dlopen search path, AutoBackend() returns
// BackendNone, every GPUBackend method returns ErrGPUNotAvailable, and
// the existing CPU verify path stays unchanged.
//
// Probe order (init()): cuda → hip → metal → vulkan → webgpu. First
// plugin that resolves the entry point AND reports a v14 ABI matching
// the header we compiled against AND exposes the three Q-Chain slots
// (op_mldsa_verify_batch, op_mldsa_sign_batch,
// op_slhdsa_verify_batch[_shake192f]) wins; remaining probes are
// skipped. The cookie checks (abi_version + vtbl_size) defend against
// a plugin that lies about its ABI version while shipping a truncated
// vtable (see backend_plugin.h §"Backend Descriptor" comment).
//
// Search path: the dlopen library name is the bare basename
// (libluxgpu_backend_<x>.{so,dylib}); the loader's standard
// LD_LIBRARY_PATH / DYLD_LIBRARY_PATH / rpath resolution finds it.
// LUX_GPU_PLUGIN_DIR overrides — if set, every probe joins it to the
// basename before calling dlopen.
package quantumvm
/*
#cgo darwin LDFLAGS: -ldl
#cgo linux LDFLAGS: -ldl
// Workspace-relative path to the ABI v14 plugin header. From
// ${SRCDIR} = ~/work/lux/chains/quantumvm we walk three levels up
// (chains → lux → work) and one down (lux-private/gpu-kernels/include)
// to reach the canonical header. External consumers building from
// $GOMODCACHE need lux-private/gpu-kernels checked out at the standard
// sibling location; this matches the convention LLM.md establishes
// for every other workspace-coupled component (LUXCPP_DIR etc.).
//
// Header-only inclusion: we DO NOT link against any luxcpp / lux-gpu
// library. The plugin DSO is dlopen'd at runtime; the only compile-
// time dependency is the C struct layout in backend_plugin.h.
#cgo CFLAGS: -I${SRCDIR}/../../../lux-private/gpu-kernels/include
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "lux/gpu/backend_plugin.h"
// ABI v14 vtbl entry-point thunk. dlsym returns void*; this struct lets
// Go pass the function pointer back through the bridge without losing
// type information at the cgo boundary.
typedef bool (*qvm_backend_init_fn)(lux_gpu_backend_desc* out);
// qvm_backend_init invokes the plugin's `lux_gpu_backend_init` symbol
// and returns 0 on success, non-zero (1..5) on a specific cookie /
// slot failure. The cookies (abi_version + vtbl_size) detect plugins
// built against a different header — fail fast at process start
// instead of UB at the first vtbl slot call (a stale plugin's
// op_mldsa_verify_batch at the wrong offset would be a junk function
// pointer dispatch otherwise).
//
// ret == 0 — desc populated AND all three Q-Chain slots non-NULL.
// ret == 1 — init function returned false (no runtime available on
// this host: missing driver, ICD, etc.). Caller treats
// as SKIP, not FAIL.
// ret == 2 — abi_version mismatch (plugin is from a different ABI
// than the Go bindings were compiled against).
// ret == 3 — vtbl_size mismatch (plugin shipped a truncated vtbl).
// ret == 4 — vtbl pointer is NULL (malformed descriptor).
// ret == 5 — vtbl is missing one of the three Q-Chain slots.
static int qvm_backend_init(void* sym, lux_gpu_backend_desc* desc) {
if (!sym || !desc) return 4;
memset(desc, 0, sizeof(*desc));
qvm_backend_init_fn fn = (qvm_backend_init_fn)sym;
if (!fn(desc)) return 1;
if (desc->abi_version != LUX_GPU_BACKEND_ABI_VERSION) return 2;
if (desc->vtbl_size != sizeof(lux_gpu_backend_vtbl)) return 3;
if (!desc->vtbl) return 4;
if (!desc->vtbl->create_context || !desc->vtbl->destroy_context) return 5;
// ML-DSA verify is the canonical Q-Chain slot. ML-DSA sign and
// SLH-DSA verify may be nullptr on backends that haven't wired
// their orchestrator yet — those methods then return
// LUX_BACKEND_ERROR_NOT_SUPPORTED at call time, which the Go
// wrapper translates to ErrGPUNotAvailable. The plugin is still
// viable if at least op_mldsa_verify_batch is wired (ML-DSA
// verify is the load-bearing Q-Chain op).
if (!desc->vtbl->op_mldsa_verify_batch) return 5;
return 0;
}
// Lifecycle helpers — exported so the Go side can open / close a
// LuxBackendContext per probe-result backend exactly once. device_index
// = 0 is the canonical "first available device" sentinel — backends
// like Metal that don't expose multi-GPU selection assume 0 and would
// throw on -1 (NSRangeException on Apple Silicon, observed).
static LuxBackendContext* qvm_create_context(const lux_gpu_backend_vtbl* vtbl) {
return vtbl->create_context(0);
}
static void qvm_destroy_context(const lux_gpu_backend_vtbl* vtbl, LuxBackendContext* ctx) {
if (ctx) vtbl->destroy_context(ctx);
}
// Per-slot wrappers. The trampolines unpack Go's flat parameter slices
// and dispatch through the vtbl function pointer. Each returns the raw
// LuxBackendError code — the Go side maps NOT_SUPPORTED (3) →
// ErrGPUNotAvailable and other non-zero codes → fmt.Errorf("rc=%d").
// op_mldsa_verify_batch — see backend_plugin.h v14 block. msg_lens is
// optional (NULL → use msg_width_hint for every element).
static int qvm_op_mldsa_verify(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* const* pubkeys,
const uint8_t* const* messages,
const size_t* msg_lens,
uint32_t msg_width_hint,
const uint8_t* const* signatures,
bool* results,
size_t count) {
if (!vtbl->op_mldsa_verify_batch) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_mldsa_verify_batch(
ctx, pubkeys, messages, msg_lens,
msg_width_hint, signatures, results, count);
}
// op_mldsa_sign_batch — see backend_plugin.h v14 block. sig_lens_out is
// optional (NULL is fine — caller falls back to per-element verify if
// it needs accept / reject feedback).
static int qvm_op_mldsa_sign(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* skeys,
size_t sk_stride,
const uint8_t* msgs,
const size_t* msg_lens,
uint32_t msg_width_hint,
size_t count,
uint8_t* sigs_out,
uint32_t* sig_lens_out) {
if (!vtbl->op_mldsa_sign_batch) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_mldsa_sign_batch(
ctx, skeys, sk_stride, msgs, msg_lens,
msg_width_hint, count, sigs_out, sig_lens_out);
}
// op_slhdsa_verify_batch — SHAKE-128f. See backend_plugin.h ABI v13.
static int qvm_op_slhdsa_verify_128f(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* const* pubkeys,
const uint8_t* const* messages,
const size_t* msg_lens,
const uint8_t* const* signatures,
bool* results,
size_t count) {
if (!vtbl->op_slhdsa_verify_batch) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_slhdsa_verify_batch(
ctx, pubkeys, messages, msg_lens, signatures, results, count);
}
// op_slhdsa_verify_batch_shake192f — SHAKE-192f. See backend_plugin.h
// ABI v13.
static int qvm_op_slhdsa_verify_192f(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* const* pubkeys,
const uint8_t* const* messages,
const size_t* msg_lens,
const uint8_t* const* signatures,
bool* results,
size_t count) {
if (!vtbl->op_slhdsa_verify_batch_shake192f) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_slhdsa_verify_batch_shake192f(
ctx, pubkeys, messages, msg_lens, signatures, results, count);
}
// dlopen / dlsym wrappers — kept here so backend.go stays pure Go.
static void* qvm_dlopen(const char* path) {
return dlopen(path, RTLD_NOW | RTLD_LOCAL);
}
static void* qvm_dlsym(void* handle, const char* sym) {
return dlsym(handle, sym);
}
static const char* qvm_dlerror(void) {
return dlerror();
}
static void qvm_dlclose(void* handle) {
if (handle) dlclose(handle);
}
// Allocates a lux_gpu_backend_desc the Go side keeps for the lifetime
// of the GPUBackend. Heap-allocated so the Go object's address can be
// shared across cgo calls without escape-analysis surprises.
static lux_gpu_backend_desc* qvm_alloc_desc(void) {
lux_gpu_backend_desc* d = (lux_gpu_backend_desc*)calloc(1, sizeof(*d));
return d;
}
static void qvm_free_desc(lux_gpu_backend_desc* d) {
free(d);
}
*/
import "C"
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"unsafe"
)
// pluginEntrySymbol is the canonical ABI v14 entry point exported by every
// libluxgpu_backend_<x>.{so,dylib} plugin. Resolving this symbol yields a
// function pointer that populates a lux_gpu_backend_desc with abi_version,
// vtbl_size cookies, and the vtbl pointer the rest of this file walks.
const pluginEntrySymbol = "lux_gpu_backend_init"
// pluginEnv overrides the dlopen search path. When set, every probe joins
// the env value to the bare basename before calling dlopen — useful for
// in-tree development against a freshly-built plugin under
// ~/work/lux-private/gpu-kernels/build/ without installing it.
const pluginEnv = "LUX_GPU_PLUGIN_DIR"
// probeOrder is the dlopen probe sequence fixed by the spec:
// cuda → hip → metal → vulkan → webgpu. First plugin that satisfies the
// cookie checks AND exposes the load-bearing op_mldsa_verify_batch slot
// wins; the remaining probes are skipped. Order is preserved at struct
// definition (not encoded as a map) so the iteration order is the same
// on every platform.
var probeOrder = []struct {
kind Backend
name string // basename without lib prefix or .so/.dylib suffix
}{
{BackendCUDA, "luxgpu_backend_cuda"},
{BackendHIP, "luxgpu_backend_hip"},
{BackendMetal, "luxgpu_backend_metal"},
{BackendVulkan, "luxgpu_backend_vulkan"},
{BackendWebGPU, "luxgpu_backend_webgpu"},
}
// pluginBasenames returns the platform-specific DSO basenames to attempt
// for a given backend. On darwin we try both .dylib (native) and .so
// (cross-tooling fallback). On linux / other we try .so first. The
// order within each entry's slice matches the lookup order used by
// dlopen — first hit wins.
func pluginBasenames(name string) []string {
switch runtime.GOOS {
case "darwin":
return []string{
"lib" + name + ".dylib",
"lib" + name + ".so",
}
case "windows":
return []string{
name + ".dll",
}
default:
return []string{
"lib" + name + ".so",
}
}
}
// =============================================================================
// gpuBackend — the cgo implementation of the GPUBackend interface.
//
// One struct per loaded plugin; pinned at package level by init()'s probe.
// All vtbl invocations route through C trampolines (qvm_op_*) defined in
// the cgo preamble above. mu serialises Close() against concurrent op
// dispatch — every vtbl call increments / decrements an in-flight counter
// so Close() can wait for outstanding calls before destroying the context.
// =============================================================================
type gpuBackend struct {
mu sync.Mutex
kind Backend
handle unsafe.Pointer // dlopen result (void*)
desc *C.lux_gpu_backend_desc // heap-allocated descriptor
ctx *C.LuxBackendContext // backend-managed opaque context
path string
}
// init runs the dlopen probe exactly once at process start. The probe
// walks probeOrder, attempts each candidate basename, and stops at the
// first plugin that:
//
// 1. Successfully dlopens.
// 2. Exposes the lux_gpu_backend_init symbol.
// 3. Reports abi_version == LUX_GPU_BACKEND_ABI_VERSION (14).
// 4. Reports vtbl_size == sizeof(lux_gpu_backend_vtbl) on our header.
// 5. Has op_mldsa_verify_batch wired (the load-bearing Q-Chain slot).
// 6. Successfully opens a LuxBackendContext via vtbl->create_context.
//
// On success the package-level activeBackend atomic is set to the
// matching Backend enum value. Failure at any stage is silent — we move
// to the next candidate. If no candidate succeeds, activeBackend stays
// BackendNone and ActiveGPUBackend() returns a noGPUBackend stub.
func init() {
if b, ok := tryProbe(); ok {
setActiveBackend(b.kind)
setActiveGPU(b)
return
}
setActiveBackend(BackendNone)
setActiveGPU(nil)
}
// tryProbe runs the probeOrder dlopen loop. Returns the loaded gpuBackend
// on success, or (nil, false) if no candidate satisfied all six checks.
// Errors are deliberately not surfaced — this is a probe, not a hard
// requirement. Callers see BackendNone and route to the CPU path.
func tryProbe() (*gpuBackend, bool) {
dir := os.Getenv(pluginEnv)
for _, p := range probeOrder {
for _, bn := range pluginBasenames(p.name) {
path := bn
if dir != "" {
path = filepath.Join(dir, bn)
}
b, err := openPlugin(p.kind, path)
if err == nil {
return b, true
}
}
}
return nil, false
}
// openPlugin attempts to dlopen `path`, resolve lux_gpu_backend_init,
// run the cookie checks, and create a backend context. Returns the
// loaded gpuBackend on success, or (nil, err) with the offending stage
// documented in the error. The dlopen handle is closed on any failure
// past the dlopen point — we never leak a half-bound plugin.
func openPlugin(kind Backend, path string) (*gpuBackend, error) {
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
// Clear pending dlerror so a stale message from a prior failed call
// doesn't get mis-attributed to this dlopen.
C.qvm_dlerror()
handle := C.qvm_dlopen(cpath)
if handle == nil {
return nil, fmt.Errorf("quantumvm: dlopen(%s): %s",
path, C.GoString(C.qvm_dlerror()))
}
csym := C.CString(pluginEntrySymbol)
defer C.free(unsafe.Pointer(csym))
C.qvm_dlerror()
sym := C.qvm_dlsym(handle, csym)
if sym == nil {
C.qvm_dlclose(handle)
return nil, fmt.Errorf("quantumvm: dlsym(%s, %s): %s",
path, pluginEntrySymbol, C.GoString(C.qvm_dlerror()))
}
desc := C.qvm_alloc_desc()
if desc == nil {
C.qvm_dlclose(handle)
return nil, fmt.Errorf("quantumvm: calloc(lux_gpu_backend_desc) failed")
}
if rc := C.qvm_backend_init(sym, desc); rc != 0 {
C.qvm_free_desc(desc)
C.qvm_dlclose(handle)
return nil, fmt.Errorf(
"quantumvm: %s plugin init failed (rc=%d, path=%s)",
kind, int(rc), path)
}
ctx := C.qvm_create_context(desc.vtbl)
if ctx == nil {
C.qvm_free_desc(desc)
C.qvm_dlclose(handle)
return nil, fmt.Errorf(
"quantumvm: %s create_context returned NULL (no runtime on this host?)",
kind)
}
return &gpuBackend{
kind: kind,
handle: handle,
desc: desc,
ctx: ctx,
path: path,
}, nil
}
// =============================================================================
// Active-backend handle plumbing.
//
// activeGPU stores the *gpuBackend resolved by init()'s probe (or nil
// when no plugin loaded). Read via ActiveGPUBackend() — which returns
// the GPUBackend interface (gpuBackend implements it). The pointer is
// pinned at init time and never mutated thereafter; we still serialise
// access through a Mutex for safety against a future caller that might
// add an explicit re-probe path.
// =============================================================================
var (
activeGPUMu sync.RWMutex
activeGPU *gpuBackend
)
func setActiveGPU(b *gpuBackend) {
activeGPUMu.Lock()
activeGPU = b
activeGPUMu.Unlock()
}
// ActiveGPUBackend returns the package-level GPUBackend handle. Under
// cgo this is the *gpuBackend chosen by init()'s probe, or a sentinel
// noGPUBackend stub when no plugin loaded. The returned handle's
// methods are safe to call from any goroutine — every vtbl call
// holds the backend mutex.
func ActiveGPUBackend() GPUBackend {
activeGPUMu.RLock()
b := activeGPU
activeGPUMu.RUnlock()
if b == nil {
return noGPUBackend{}
}
return b
}
// noGPUBackend is the cgo build's stub for the "no plugin loaded" case.
// Distinct from the !cgo noGPUBackend so the !cgo file can ship without
// any cgo machinery; same method semantics.
type noGPUBackend struct{}
func (noGPUBackend) Backend() Backend { return BackendNone }
func (noGPUBackend) Close() error { return nil }
func (noGPUBackend) MLDSAVerifyBatch(
mode MLDSAMode,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
msgWidthHint uint32,
signatures [][]byte,
results []bool,
) error {
_ = mode
_ = messages
_ = msgLens
_ = msgWidthHint
_ = signatures
_ = results
if len(pubkeys) == 0 {
return nil
}
return ErrGPUNotAvailable
}
func (noGPUBackend) MLDSASignBatch(
mode MLDSAMode,
skeys []byte,
msgs []byte,
msgLens []int,
msgWidthHint uint32,
count int,
sigsOut []byte,
sigLensOut []uint32,
) error {
_ = mode
_ = skeys
_ = msgs
_ = msgLens
_ = msgWidthHint
_ = sigsOut
_ = sigLensOut
if count == 0 {
return nil
}
return ErrGPUNotAvailable
}
func (noGPUBackend) SLHDSAVerifyBatch(
variant SLHDSAVariant,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
signatures [][]byte,
results []bool,
) error {
_ = variant
_ = messages
_ = msgLens
_ = signatures
_ = results
if len(pubkeys) == 0 {
return nil
}
return ErrGPUNotAvailable
}
// =============================================================================
// gpuBackend method implementations.
// =============================================================================
func (b *gpuBackend) Backend() Backend {
if b == nil {
return BackendNone
}
return b.kind
}
// Close destroys the LuxBackendContext, dlcloses the plugin, and frees
// the descriptor. Safe on a nil receiver and idempotent — a second call
// is a no-op once the handle has been cleared.
func (b *gpuBackend) Close() error {
if b == nil {
return nil
}
b.mu.Lock()
defer b.mu.Unlock()
if b.handle == nil {
return nil
}
if b.ctx != nil {
C.qvm_destroy_context(b.desc.vtbl, b.ctx)
b.ctx = nil
}
if b.desc != nil {
C.qvm_free_desc(b.desc)
b.desc = nil
}
C.qvm_dlclose(b.handle)
b.handle = nil
return nil
}
// MLDSAVerifyBatch dispatches op_mldsa_verify_batch from the loaded
// vtbl. See the GPUBackend interface doc + backend_plugin.h v14 block
// for the contract.
func (b *gpuBackend) MLDSAVerifyBatch(
mode MLDSAMode,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
msgWidthHint uint32,
signatures [][]byte,
results []bool,
) error {
if mode != MLDSAMode65 {
// v14 vtbl only exposes ML-DSA-65 at op_mldsa_verify_batch.
// Modes 44 / 87 will land at sibling slots in a future ABI
// bump (see backend_plugin.h v14 block).
return ErrGPUNotAvailable
}
n := len(pubkeys)
if n == 0 {
return nil
}
if len(messages) != n || len(signatures) != n || len(results) != n {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch: length mismatch (pubkeys=%d messages=%d signatures=%d results=%d)",
n, len(messages), len(signatures), len(results))
}
if msgLens != nil && len(msgLens) != n {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch: msgLens length %d != batch %d",
len(msgLens), n)
}
if b == nil || b.ctx == nil || b.desc == nil {
return ErrGPUNotAvailable
}
b.mu.Lock()
defer b.mu.Unlock()
// Pin every Go-side byte slice for the duration of the C call.
// runtime.Pinner guarantees the GC won't move (or collect) the
// backing arrays while the kernel reads them.
var pinner runtime.Pinner
defer pinner.Unpin()
pkPtrs := make([]*C.uint8_t, n)
msgPtrs := make([]*C.uint8_t, n)
sigPtrs := make([]*C.uint8_t, n)
var lensBuf []C.size_t
for i := 0; i < n; i++ {
if len(pubkeys[i]) != MLDSA65PublicKeySize {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch[%d]: pubkey len %d != %d",
i, len(pubkeys[i]), MLDSA65PublicKeySize)
}
if len(signatures[i]) > MLDSA65SignatureSize {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch[%d]: signature len %d > max %d",
i, len(signatures[i]), MLDSA65SignatureSize)
}
pinner.Pin(&pubkeys[i][0])
pkPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&pubkeys[i][0]))
// Allow zero-length message (legal — empty-message verify is
// a defined SHAKE256 absorb-zero operation per FIPS 204).
if len(messages[i]) > 0 {
pinner.Pin(&messages[i][0])
msgPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&messages[i][0]))
}
pinner.Pin(&signatures[i][0])
sigPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&signatures[i][0]))
}
if msgLens != nil {
lensBuf = make([]C.size_t, n)
for i := 0; i < n; i++ {
if msgLens[i] < 0 {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch[%d]: negative msgLen %d",
i, msgLens[i])
}
lensBuf[i] = C.size_t(msgLens[i])
}
}
// bool layout in cgo: C.bool is one byte. Stage results in a
// scratch buffer; copy back to the caller's []bool after the call.
cresults := make([]C.bool, n)
pkPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&pkPtrs[0]))
msgPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&msgPtrs[0]))
sigPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&sigPtrs[0]))
var lensPtr *C.size_t
if lensBuf != nil {
lensPtr = (*C.size_t)(unsafe.Pointer(&lensBuf[0]))
}
rc := C.qvm_op_mldsa_verify(
b.desc.vtbl,
b.ctx,
pkPtrsPtr,
msgPtrsPtr,
lensPtr,
C.uint32_t(msgWidthHint),
sigPtrsPtr,
(*C.bool)(unsafe.Pointer(&cresults[0])),
C.size_t(n),
)
runtime.KeepAlive(pkPtrs)
runtime.KeepAlive(msgPtrs)
runtime.KeepAlive(sigPtrs)
runtime.KeepAlive(lensBuf)
runtime.KeepAlive(cresults)
if rc != 0 {
return mapBackendError(int(rc), "op_mldsa_verify_batch")
}
for i := 0; i < n; i++ {
results[i] = bool(cresults[i])
}
return nil
}
// MLDSASignBatch dispatches op_mldsa_sign_batch from the loaded vtbl.
// See the GPUBackend interface doc + backend_plugin.h v14 block.
func (b *gpuBackend) MLDSASignBatch(
mode MLDSAMode,
skeys []byte,
msgs []byte,
msgLens []int,
msgWidthHint uint32,
count int,
sigsOut []byte,
sigLensOut []uint32,
) error {
if mode != MLDSAMode65 {
return ErrGPUNotAvailable
}
if count == 0 {
return nil
}
const skStride = MLDSA65SecretKeySize
const sigStride = MLDSA65SignatureSize
if len(skeys) != count*skStride {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: skeys len %d != count*%d (%d)",
len(skeys), skStride, count*skStride)
}
if len(sigsOut) != count*sigStride {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: sigsOut len %d != count*%d (%d)",
len(sigsOut), sigStride, count*sigStride)
}
if msgLens != nil && len(msgLens) != count {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: msgLens length %d != count %d",
len(msgLens), count)
}
if sigLensOut != nil && len(sigLensOut) != count {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: sigLensOut length %d != count %d",
len(sigLensOut), count)
}
if b == nil || b.ctx == nil || b.desc == nil {
return ErrGPUNotAvailable
}
// Validate the msgs pool is large enough to cover the per-element
// lengths. NULL msgLens uses msgWidthHint uniformly — same check
// against count * msgWidthHint.
var total int
if msgLens != nil {
for i := 0; i < count; i++ {
if msgLens[i] < 0 {
return fmt.Errorf(
"quantumvm: MLDSASignBatch[%d]: negative msgLen %d",
i, msgLens[i])
}
total += msgLens[i]
}
} else {
total = int(msgWidthHint) * count
}
if len(msgs) < total {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: msgs pool len %d < total %d",
len(msgs), total)
}
b.mu.Lock()
defer b.mu.Unlock()
var pinner runtime.Pinner
defer pinner.Unpin()
pinner.Pin(&skeys[0])
if total > 0 {
pinner.Pin(&msgs[0])
}
pinner.Pin(&sigsOut[0])
var lensBuf []C.size_t
var lensPtr *C.size_t
if msgLens != nil {
lensBuf = make([]C.size_t, count)
for i := 0; i < count; i++ {
lensBuf[i] = C.size_t(msgLens[i])
}
lensPtr = (*C.size_t)(unsafe.Pointer(&lensBuf[0]))
}
var sigLensPtr *C.uint32_t
if sigLensOut != nil {
sigLensPtr = (*C.uint32_t)(unsafe.Pointer(&sigLensOut[0]))
}
var msgsPtr *C.uint8_t
if total > 0 {
msgsPtr = (*C.uint8_t)(unsafe.Pointer(&msgs[0]))
}
rc := C.qvm_op_mldsa_sign(
b.desc.vtbl,
b.ctx,
(*C.uint8_t)(unsafe.Pointer(&skeys[0])),
C.size_t(skStride),
msgsPtr,
lensPtr,
C.uint32_t(msgWidthHint),
C.size_t(count),
(*C.uint8_t)(unsafe.Pointer(&sigsOut[0])),
sigLensPtr,
)
runtime.KeepAlive(lensBuf)
if rc != 0 {
return mapBackendError(int(rc), "op_mldsa_sign_batch")
}
return nil
}
// SLHDSAVerifyBatch dispatches the SHAKE-128f or SHAKE-192f variant of
// op_slhdsa_verify_batch from the loaded vtbl. The 10 other FIPS 205
// variants are not exposed at v14 — they return ErrGPUNotAvailable.
func (b *gpuBackend) SLHDSAVerifyBatch(
variant SLHDSAVariant,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
signatures [][]byte,
results []bool,
) error {
is128f := variant == SLHDSAShake128f
is192f := variant == SLHDSAShake192f
if !is128f && !is192f {
return ErrGPUNotAvailable
}
n := len(pubkeys)
if n == 0 {
return nil
}
if len(messages) != n || len(signatures) != n || len(results) != n {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch: length mismatch (pubkeys=%d messages=%d signatures=%d results=%d)",
n, len(messages), len(signatures), len(results))
}
if msgLens != nil && len(msgLens) != n {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch: msgLens length %d != batch %d",
len(msgLens), n)
}
pkSize := variant.PublicKeySize()
sigSize := variant.SignatureSize()
if b == nil || b.ctx == nil || b.desc == nil {
return ErrGPUNotAvailable
}
b.mu.Lock()
defer b.mu.Unlock()
var pinner runtime.Pinner
defer pinner.Unpin()
pkPtrs := make([]*C.uint8_t, n)
msgPtrs := make([]*C.uint8_t, n)
sigPtrs := make([]*C.uint8_t, n)
lensBuf := make([]C.size_t, n)
for i := 0; i < n; i++ {
if len(pubkeys[i]) != pkSize {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch[%d]: pubkey len %d != %d",
i, len(pubkeys[i]), pkSize)
}
if len(signatures[i]) != sigSize {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch[%d]: signature len %d != %d",
i, len(signatures[i]), sigSize)
}
pinner.Pin(&pubkeys[i][0])
pkPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&pubkeys[i][0]))
if len(messages[i]) > 0 {
pinner.Pin(&messages[i][0])
msgPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&messages[i][0]))
}
pinner.Pin(&signatures[i][0])
sigPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&signatures[i][0]))
// SLH-DSA has no msg_width_hint surface — msg_lens is
// mandatory at the vtbl boundary. Fill lensBuf from either
// msgLens or the message slice length.
if msgLens != nil {
if msgLens[i] < 0 {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch[%d]: negative msgLen %d",
i, msgLens[i])
}
lensBuf[i] = C.size_t(msgLens[i])
} else {
lensBuf[i] = C.size_t(len(messages[i]))
}
}
cresults := make([]C.bool, n)
pkPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&pkPtrs[0]))
msgPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&msgPtrs[0]))
sigPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&sigPtrs[0]))
lensPtr := (*C.size_t)(unsafe.Pointer(&lensBuf[0]))
var rc C.int
if is128f {
rc = C.qvm_op_slhdsa_verify_128f(
b.desc.vtbl,
b.ctx,
pkPtrsPtr,
msgPtrsPtr,
lensPtr,
sigPtrsPtr,
(*C.bool)(unsafe.Pointer(&cresults[0])),
C.size_t(n),
)
} else {
rc = C.qvm_op_slhdsa_verify_192f(
b.desc.vtbl,
b.ctx,
pkPtrsPtr,
msgPtrsPtr,
lensPtr,
sigPtrsPtr,
(*C.bool)(unsafe.Pointer(&cresults[0])),
C.size_t(n),
)
}
runtime.KeepAlive(pkPtrs)
runtime.KeepAlive(msgPtrs)
runtime.KeepAlive(sigPtrs)
runtime.KeepAlive(lensBuf)
runtime.KeepAlive(cresults)
if rc != 0 {
return mapBackendError(int(rc), "op_slhdsa_verify_batch")
}
for i := 0; i < n; i++ {
results[i] = bool(cresults[i])
}
return nil
}
// mapBackendError translates a LuxBackendError code to a Go error.
// NOT_SUPPORTED (3) maps to the package sentinel so callers can route
// to the CPU oracle via errors.Is. Other non-zero codes return a
// formatted error including the numeric code for diagnostics.
func mapBackendError(rc int, op string) error {
switch rc {
case 0:
return nil
case int(C.LUX_BACKEND_ERROR_NOT_SUPPORTED):
return ErrGPUNotAvailable
case int(C.LUX_BACKEND_ERROR_INVALID_ARGUMENT):
return fmt.Errorf("quantumvm: %s: invalid argument (rc=1)", op)
case int(C.LUX_BACKEND_ERROR_OUT_OF_MEMORY):
return fmt.Errorf("quantumvm: %s: out of memory (rc=2)", op)
case int(C.LUX_BACKEND_ERROR_DEVICE_LOST):
return fmt.Errorf("quantumvm: %s: device lost (rc=4)", op)
case int(C.LUX_BACKEND_ERROR_INTERNAL):
return fmt.Errorf("quantumvm: %s: internal error (rc=5)", op)
default:
return fmt.Errorf("quantumvm: %s: rc=%d", op, rc)
}
}
+98
View File
@@ -0,0 +1,98 @@
//go:build !cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quantumvm
// !cgo build: no plugin DSO is loadable without cgo (we cannot call
// dlopen / dlsym from pure Go), so every GPUBackend method returns
// ErrGPUNotAvailable and AutoBackend() reports BackendNone. The cgo
// build's quantumvm_gpu.go does the runtime probe; this file keeps the
// package surface identical regardless of CGO_ENABLED.
func init() {
setActiveBackend(BackendNone)
}
// noGPUBackend is the !cgo implementation of the GPUBackend interface —
// every method returns ErrGPUNotAvailable so callers route to the CPU
// verify path cleanly.
type noGPUBackend struct{}
// ActiveGPUBackend returns the package-level GPUBackend handle. Under
// !cgo this is always a stub returning ErrGPUNotAvailable on every
// method call.
func ActiveGPUBackend() GPUBackend {
return noGPUBackend{}
}
func (noGPUBackend) Backend() Backend { return BackendNone }
func (noGPUBackend) Close() error { return nil }
func (noGPUBackend) MLDSAVerifyBatch(
mode MLDSAMode,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
msgWidthHint uint32,
signatures [][]byte,
results []bool,
) error {
_ = mode
_ = messages
_ = msgLens
_ = msgWidthHint
_ = signatures
_ = results
// Empty batch is a legal no-op — match the cgo implementation's
// short-circuit so callers don't have to special-case "no plugin
// loaded AND empty batch" at the call site.
if len(pubkeys) == 0 {
return nil
}
return ErrGPUNotAvailable
}
func (noGPUBackend) MLDSASignBatch(
mode MLDSAMode,
skeys []byte,
msgs []byte,
msgLens []int,
msgWidthHint uint32,
count int,
sigsOut []byte,
sigLensOut []uint32,
) error {
_ = mode
_ = skeys
_ = msgs
_ = msgLens
_ = msgWidthHint
_ = sigsOut
_ = sigLensOut
if count == 0 {
return nil
}
return ErrGPUNotAvailable
}
func (noGPUBackend) SLHDSAVerifyBatch(
variant SLHDSAVariant,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
signatures [][]byte,
results []bool,
) error {
_ = variant
_ = messages
_ = msgLens
_ = signatures
_ = results
if len(pubkeys) == 0 {
return nil
}
return ErrGPUNotAvailable
}
+176
View File
@@ -0,0 +1,176 @@
//go:build cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quantumvm
import (
"crypto/rand"
"errors"
"testing"
"github.com/luxfi/crypto/mldsa"
)
// TestAutoBackend_Probes documents what the init() probe found at process
// start. Skipped when no plugin loaded — this is the on-CI / clean-build
// signal that the binary stays portable. When a plugin IS loaded, we log
// its name so flakes can be tied back to a specific backend.
func TestAutoBackend_Probes(t *testing.T) {
b := AutoBackend()
if b == BackendNone {
t.Skip("no libluxgpu_backend_*.{so,dylib} loadable on the dlopen path — Q-Chain CPU path remains the only option")
}
t.Logf("AutoBackend() = %s", b)
}
// TestActiveGPUBackend_NoBackendReturnsErr exercises the noGPUBackend
// stub returned when no plugin was loaded. Every method must return
// ErrGPUNotAvailable so callers can route to the CPU verify path via
// errors.Is.
func TestActiveGPUBackend_NoBackendReturnsErr(t *testing.T) {
if AutoBackend() != BackendNone {
t.Skip("plugin loaded — noGPUBackend stub not exercised on this host")
}
g := ActiveGPUBackend()
if g.Backend() != BackendNone {
t.Fatalf("Backend() = %s, want none", g.Backend())
}
err := g.MLDSAVerifyBatch(MLDSAMode65, nil, nil, nil, 0, nil, nil)
if err != nil {
// Empty batch is a legal no-op even when no plugin loaded;
// length 0 short-circuits before the noGPUBackend stub runs.
t.Fatalf("MLDSAVerifyBatch(empty) = %v, want nil", err)
}
err = g.MLDSAVerifyBatch(MLDSAMode65,
[][]byte{make([]byte, MLDSA65PublicKeySize)},
[][]byte{nil},
nil, 0,
[][]byte{make([]byte, MLDSA65SignatureSize)},
make([]bool, 1))
if !errors.Is(err, ErrGPUNotAvailable) {
t.Fatalf("MLDSAVerifyBatch on noGPUBackend = %v, want ErrGPUNotAvailable", err)
}
}
// TestMLDSAVerifyBatch_RoundTrip — open a backend context, sign one
// message via the in-process circl ML-DSA-65 implementation, then run
// op_mldsa_verify_batch on the (pk, msg, sig) triple and assert
// results[0] == true.
//
// The plugin's wrapper consumes FIPS 204 wire-format (pk=1952 B,
// sig=3309 B max) which is exactly what circl's MarshalBinary() /
// SignTo() emit, so the round-trip is byte-compatible — no PQClean
// detour needed in the test.
//
// Skip conditions:
// * No plugin loaded -> AutoBackend() == BackendNone
// * Plugin loaded but op_mldsa_verify_batch is a -> rc=NOT_SUPPORTED
// CPU oracle returning NOT_SUPPORTED (e.g. some
// WebGPU phase-2 builds) — surfaces as ErrGPUNotAvailable.
//
// Either skip is a CLEAN exit, not a FAIL. The property under test is
// "ABI v14 wire-format round-trips through the loaded plugin", which
// is only meaningful when the plugin actually runs the verify path.
func TestMLDSAVerifyBatch_RoundTrip(t *testing.T) {
if AutoBackend() == BackendNone {
t.Skip("no GPU plugin loaded — round-trip test requires the dlopen probe to have succeeded at init()")
}
g := ActiveGPUBackend()
t.Logf("backend = %s", g.Backend())
// Generate one ML-DSA-65 keypair + signature via circl. The test
// is single-shot so we don't bother batching beyond N=1; the v14
// vtbl wrapper has the same shape for every batch size.
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("mldsa.GenerateKey: %v", err)
}
pkBytes := priv.PublicKey.Bytes()
if len(pkBytes) != MLDSA65PublicKeySize {
t.Fatalf("pubkey size = %d, want %d", len(pkBytes), MLDSA65PublicKeySize)
}
// 64-byte message — matches what the Q-Chain quantum stamp path
// hands the verifier (sha512 digest of the round digest + nonce +
// timestamp). Using a deterministic byte pattern keeps the test
// reproducible across reruns.
msg := make([]byte, 64)
for i := range msg {
msg[i] = byte(i)
}
// Sign with ctx=nil to match the orchestrator's ctx_len=0 wrap.
sig, err := priv.Sign(rand.Reader, msg, nil)
if err != nil {
t.Fatalf("priv.Sign: %v", err)
}
if got, max := len(sig), MLDSA65SignatureSize; got > max {
t.Fatalf("sig size = %d > max %d", got, max)
}
// Pad the signature out to the fixed 3309-byte width the orchestrator
// reads. Padding bytes after the real sig length are ignored by the
// FIPS 204 verify path (sig parsing terminates at the c̃ + z + h
// structural decode).
sigPadded := make([]byte, MLDSA65SignatureSize)
copy(sigPadded, sig)
results := make([]bool, 1)
err = g.MLDSAVerifyBatch(
MLDSAMode65,
[][]byte{pkBytes},
[][]byte{msg},
[]int{len(msg)},
/*msgWidthHint=*/ 0,
[][]byte{sigPadded},
results,
)
if errors.Is(err, ErrGPUNotAvailable) {
t.Skipf("plugin op_mldsa_verify_batch returned NOT_SUPPORTED (backend=%s) — surface contract honoured, round-trip not testable", g.Backend())
}
if err != nil {
t.Fatalf("MLDSAVerifyBatch: %v", err)
}
if !results[0] {
t.Fatalf("honest verify returned false (backend=%s) — wire-format incompatibility", g.Backend())
}
t.Logf("backend=%s op_mldsa_verify_batch honest verify -> true", g.Backend())
}
// TestMLDSAVerifyBatch_LengthMismatch covers the length-validation
// branch at the boundary. The wrapper must reject batches where the
// per-element slices disagree on cardinality before any C call.
func TestMLDSAVerifyBatch_LengthMismatch(t *testing.T) {
if AutoBackend() == BackendNone {
t.Skip("no GPU plugin loaded")
}
g := ActiveGPUBackend()
err := g.MLDSAVerifyBatch(
MLDSAMode65,
make([][]byte, 2),
make([][]byte, 1),
nil, 0,
make([][]byte, 2),
make([]bool, 2),
)
if err == nil {
t.Fatal("expected length-mismatch error, got nil")
}
}
// TestMLDSAVerifyBatch_NonMLDSA65Skips exercises the mode gate. Modes
// 44 / 87 aren't wired at v14 — passing them must surface as
// ErrGPUNotAvailable so callers fall through cleanly.
func TestMLDSAVerifyBatch_NonMLDSA65Skips(t *testing.T) {
g := ActiveGPUBackend()
for _, mode := range []MLDSAMode{MLDSAMode44, MLDSAMode87} {
err := g.MLDSAVerifyBatch(mode,
[][]byte{make([]byte, 1)},
[][]byte{nil}, nil, 0,
[][]byte{make([]byte, 1)},
make([]bool, 1))
if !errors.Is(err, ErrGPUNotAvailable) {
t.Fatalf("mode %d: got %v, want ErrGPUNotAvailable", mode, err)
}
}
}