bridgevm: wire to GPU plugin substrate via dlopen + dlsym

Mirrors the evm/cevm pattern (cevm_cgo.go / cevm_nocgo.go +
backend_cgo.go). Three new files in bridgevm/:

  backend.go             — Backend enum, layout structs (byte-equal to
                            ops/bridgevm/cuda/bridgevm_kernels_common.cuh),
                            ErrGPUNotAvailable, GPUBackend interface,
                            unsafe.Sizeof asserts in init().
  bridgevm_gpu.go    cgo  — direct dlopen(3) + dlsym(3) for the per-backend
                            plugin libluxgpu_backend_<bk>.{so,dylib}.
                            Probes cuda → hip → metal → vulkan → webgpu in
                            order; first plugin exposing all five
                            lux_<bk>_bridgevm_* launchers wins.
  bridgevm_gpu_nocgo.go  — stub returning ErrGPUNotAvailable on every method.

Direct dlopen (NOT pkg-config) so the public chains module builds without
the private gpu-kernels repo present. Search path: \$LUX_GPU_PLUGIN_DIR,
\$LUXCPP_PREFIX/lib/lux-gpu, \$LUXCPP_PREFIX/lib, the dev tree under
~/work/lux-private/gpu-kernels/build/backends/<bk>, CWD, and finally the
system loader's default.

Five launcher signatures dispatched via cgo trampolines (one per shape) —
matches the C ABI in include/lux/gpu/bridgevm.h. Layout structs include
explicit '_pad*' fields to keep Go sizeof() byte-equal to the C++
alignas(16) layout; the init() sizeof asserts catch any drift at process
start with a per-struct panic message.

vm.go / bridge_signing.go / mpc.go are NOT touched — this is a thin
substrate; the round applier opts in later when it's ready to route
through ActiveGPUBackend().

bridgevm_gpu_test.go — four tests under //go:build cgo:
  TestActiveBackend          — AutoBackend() returns a known tag.
  TestStubReturnsErrGPUNotAvailable
                              — BackendNone path returns the sentinel.
  TestLayoutSizesMatchHeader — runtime-equivalent of the C++ static_asserts.
  TestSignerApplyZeroFixture — dlopen + dlsym + zero-fixture round trip
                                through lux_<best>_bridgevm_signer_apply.

Builds verified under both CGO_ENABLED=0 (nocgo path active) and
CGO_ENABLED=1 -tags cgo (cgo path active). All four cgo-tagged tests
pass on the local dev box (Vulkan plugin auto-detected from the
gpu-kernels build tree).
This commit is contained in:
Hanzo Dev
2026-06-05 14:53:18 -07:00
parent fa58e4a888
commit 26d9381743
4 changed files with 1229 additions and 0 deletions
+429
View File
@@ -0,0 +1,429 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
// BridgeVM GPU substrate — host-side ABI for the five BridgeVM kernels
// (signer_apply, liquidity_apply, message_inbox, message_outbox, transition).
//
// The kernels live in ~/work/lux-private/gpu-kernels under
// ops/bridgevm/<backend>/ + backends/<backend>/src/bridgevm_launchers.{cpp,mm}
// and are shipped inside the plugin shared libraries libluxgpu_backend_<x>.so /
// .dylib. The Go side dlopens whichever plugin is on disk at process start
// and dlsyms the five lux_<backend>_bridgevm_* symbols.
//
// Pattern (mirrors evm/cevm + evm/backend_cgo.go):
//
// backend.go — shared types: Backend enum, layout structs,
// ErrGPUNotAvailable, GPUBackend interface,
// sizeof asserts (build-tag-free; runtime
// probe lives in the cgo/nocgo files below).
// bridgevm_gpu.go cgo — dlopen + dlsym for libluxgpu_backend_<x>.
// bridgevm_gpu_nocgo.go !cgo — stub returning ErrGPUNotAvailable.
//
// Layout structs are byte-equal to
// ops/bridgevm/cuda/bridgevm_kernels_common.cuh (and the matching CPU oracle
// at luxcpp/bridgevm/src/bridgevm_cpu_reference.cpp). The init() sizeof
// asserts catch any drift at process start instead of producing silently
// wrong roots.
import (
"errors"
"fmt"
"sync/atomic"
"unsafe"
)
// Backend identifies which GPU plugin is currently active.
//
// Order matches the dlopen probe order in bridgevm_gpu.go's init():
// cuda → hip → metal → vulkan → webgpu. The first plugin that resolves
// all five lux_<backend>_bridgevm_* symbols wins; remaining probes are
// skipped.
type Backend uint8
const (
// BackendNone means no GPU plugin is loaded — calls return
// ErrGPUNotAvailable. This is the value reported by AutoBackend()
// under !cgo, and under cgo when no libluxgpu_backend_*.so is on
// the dlopen search path.
BackendNone Backend = 0
// BackendCUDA selects libluxgpu_backend_cuda.so (NVIDIA, Linux/Windows).
BackendCUDA Backend = 1
// BackendHIP selects libluxgpu_backend_hip.so (AMD, 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 human-readable name of the backend — matches the symbol
// prefix component used by the dlsym probe (lux_<name>_bridgevm_*).
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 found on the dlopen search path, or
// the loaded plugin doesn't expose the lux_<backend>_bridgevm_* launchers
// (e.g. an older plugin built before the bridgevm op landed).
//
// The error is sentinel-comparable via errors.Is so callers can route a
// CPU-oracle fallback cleanly:
//
// if errors.Is(err, bridgevm.ErrGPUNotAvailable) {
// return cpuOracle.Apply(...)
// }
var ErrGPUNotAvailable = errors.New("bridgevm: GPU backend not available")
// =============================================================================
// Layout structs — byte-equal to ops/bridgevm/cuda/bridgevm_kernels_common.cuh
// and to luxcpp/bridgevm/include/lux/bridgevm/bridgevm_gpu_layout.hpp.
//
// alignas(16) on the C++ side is honoured here by manual `_pad*` fields and
// by the init() sizeof asserts below. Go's struct layout rule (no implicit
// alignment padding beyond the natural alignment of the largest field) means
// these definitions land at the same byte offsets the GPU kernels read from.
//
// `little-endian lane` ordering for 128-bit values: the C ABI splits each
// 128-bit quantity into (lo, hi) uint64 pairs in the struct — never a single
// 128-bit field — so cross-endianness reproducibility is byte-trivial.
// =============================================================================
// Signer is the on-arena per-signer record (208 bytes).
type Signer struct {
SignerID uint64
LuxAddress [20]byte
_ uint32 // _pad_addr
BondAmountLo uint64
BondAmountHi uint64
OptInHeight uint64
ExitEpoch uint64
SignCount uint64
BLSPubKey [48]byte
CoronaPubKey [32]byte
MLDSAPubKey [32]byte
Status uint32
JailUntilEpoch uint32
SlashCount uint32
Occupied uint32
_ uint64 // _pad_tail
}
// LiquidityEntry is the on-arena per-provider liquidity record (80 bytes).
type LiquidityEntry struct {
ProviderAddr [20]byte
_ uint32 // _pad_addr
AssetID uint32
Status uint32
AmountLo uint64
AmountHi uint64
FeeAccrualLo uint64
FeeAccrualHi uint64
DepositHeight uint64
_ uint64 // _pad0
}
// DailyLimit is the on-arena per-asset daily cap record (64 bytes).
type DailyLimit struct {
AssetID uint32
Status uint32
DailyCapLo uint64
DailyCapHi uint64
UsedTodayLo uint64
UsedTodayHi uint64
ResetEpoch uint64
_ uint64 // _pad0
_ uint64 // _pad1
}
// Message is an inbox/outbox cross-chain message record (240 bytes).
type Message struct {
MsgID [32]byte
PayloadRoot [32]byte
AggSignature [96]byte
SignersBitmapLo uint64
SignersBitmapHi uint64
Nonce uint64
SrcChain uint32
DstChain uint32
Kind uint32
Status uint32
AssetID uint32
SignerCount uint32
AmountLo uint64
AmountHi uint64
ArrivalHeight uint64
_ uint64 // _pad_tail
}
// BridgeVMEpochState is the epoch summary written by transition (240 bytes).
type BridgeVMEpochState struct {
CurrentEpoch uint64
NextEpochHeight uint64
TotalActiveBondLo uint64
TotalActiveBondHi uint64
ActiveSignerCount uint32
PendingDropCount uint32
InboxCount uint32
OutboxCount uint32
SignerSetRoot [32]byte
LiquidityRoot [32]byte
InboxRoot [32]byte
OutboxRoot [32]byte
DailyLimitRoot [32]byte
BridgeVMStateRoot [32]byte
}
// BridgeVMRoundDescriptor parameterises one round invocation (112 bytes).
type BridgeVMRoundDescriptor struct {
ChainID uint64
Round uint64
TimestampNs uint64
Epoch uint64
Height uint64
Mode uint32
InboundMsgCount uint32
SignerOpCount uint32
LiquidityOpCount uint32
OutboundReqCount uint32
ClosingFlag uint32
_ uint64 // _pad0
_ uint64 // _pad1
ParentStateRoot [32]byte
}
// SignerOp is one input to signer_apply (224 bytes).
type SignerOp struct {
SignerID uint64
LuxAddress [20]byte
_ uint32 // _pad_addr
BondAmountLo uint64
BondAmountHi uint64
OptInHeight uint64
BLSPubKey [48]byte
CoronaPubKey [32]byte
MLDSAPubKey [32]byte
Kind uint32
JailUntilEpoch uint32
Epoch uint32
SlashAmountLo uint32
SlashAmountHi uint32
_ uint32 // _pad0
EvidenceDigest [32]byte
}
// LiquidityOp is one input to liquidity_apply (64 bytes).
type LiquidityOp struct {
ProviderAddr [20]byte
_ uint32 // _pad_addr
AssetID uint32
Kind uint32
AmountLo uint64
AmountHi uint64
Height uint64
_ uint64 // _pad0
}
// OutboundReq is one input to message_outbox (112 bytes).
type OutboundReq struct {
PayloadRoot [32]byte
Recipient [20]byte
_ uint32 // _pad_addr
SrcChain uint32
DstChain uint32
Kind uint32
AssetID uint32
Nonce uint64
AmountLo uint64
AmountHi uint64
Height uint64
_ uint64 // _pad_tail
}
// BridgeVMTransitionResult is the populated result of transition (304 bytes).
type BridgeVMTransitionResult struct {
Status uint32
InboundApplyCount uint32
SignerApplyCount uint32
LiquidityApplyCount uint32
OutboundApplyCount uint32
ActiveSignerCount uint32
JailedCount uint32
TombstonedCount uint32
TotalActiveBondLo uint64
TotalActiveBondHi uint64
TotalInboundAmountLo uint64
TotalInboundAmountHi uint64
TotalOutboundAmountLo uint64
TotalOutboundAmountHi uint64
TotalFeesAccruedLo uint64
TotalFeesAccruedHi uint64
Epoch uint64
_ uint64 // _pad0
SignerSetRoot [32]byte
LiquidityRoot [32]byte
InboxRoot [32]byte
OutboxRoot [32]byte
DailyLimitRoot [32]byte
BridgeVMStateRoot [32]byte
}
// =============================================================================
// GPUBackend — the surface the dlopen'd plugin presents to vm.go.
//
// All five methods return ErrGPUNotAvailable when no plugin is loaded. When a
// plugin is loaded, the methods dispatch to the corresponding
// lux_<backend>_bridgevm_* host launcher via dlsym and return a non-nil
// error when the launcher reports a non-zero status code (mapped to a Go
// error including the numeric code).
//
// Buffer ownership: callers own every slice/struct passed in. The host
// launcher does H2D / D2H internally (for discrete-GPU backends like CUDA
// and HIP this means cudaMalloc + cudaMemcpy round-trips; for unified
// backends like Metal, Vulkan, and WebGPU the slice is wrapped in a
// shader-visible buffer and the launcher submits + waits inline). On
// return every output slice has been overwritten with the launcher's
// result; the caller can read them immediately, no further sync needed.
//
// The interface is intentionally narrow — five 1:1 mappings to the host
// launcher signatures in include/lux/gpu/bridgevm.h. We do NOT try to
// express composability here (a "full round" would call all five in
// sequence); composition is the caller's job (vm.go's round-applier),
// matching the orthogonal separation in op.yaml's notes.
type GPUBackend interface {
// SignerApply runs lux_<bk>_bridgevm_signer_apply over `ops`.
// `signers` is the in-place arena slice (modified). `applied` returns
// the count of ops the kernel actually applied (subject to BFT
// threshold and per-signer status). Returns ErrGPUNotAvailable when
// no plugin is loaded.
SignerApply(
desc *BridgeVMRoundDescriptor,
ops []SignerOp,
signers []Signer,
) (applied uint32, err error)
// LiquidityApply runs lux_<bk>_bridgevm_liquidity_apply over `ops`.
// Returns the count applied + the aggregated fee accrual (lo, hi).
LiquidityApply(
desc *BridgeVMRoundDescriptor,
ops []LiquidityOp,
liquidity []LiquidityEntry,
) (applied uint32, totalFeesLo uint64, totalFeesHi uint64, err error)
// MessageInbox runs lux_<bk>_bridgevm_message_inbox over `inMsgs`.
// Returns the count accepted + the aggregated inbound amount.
MessageInbox(
desc *BridgeVMRoundDescriptor,
inMsgs []Message,
signers []Signer,
daily []DailyLimit,
inbox []Message,
) (applied uint32, totalInLo uint64, totalInHi uint64, err error)
// MessageOutbox runs lux_<bk>_bridgevm_message_outbox over `reqs`.
// Returns the count emitted + the aggregated outbound amount.
MessageOutbox(
desc *BridgeVMRoundDescriptor,
reqs []OutboundReq,
daily []DailyLimit,
outbox []Message,
epoch *BridgeVMEpochState,
) (applied uint32, totalOutLo uint64, totalOutHi uint64, err error)
// BridgeTransition runs lux_<bk>_bridgevm_transition. The result is
// written into `result` and the six roots (signer_set / liquidity /
// inbox / outbox / daily_limit / bridgevm_state) are populated.
BridgeTransition(
desc *BridgeVMRoundDescriptor,
signers []Signer,
liquidity []LiquidityEntry,
daily []DailyLimit,
inbox []Message,
outbox []Message,
epoch *BridgeVMEpochState,
result *BridgeVMTransitionResult,
) error
// Backend reports which plugin is currently loaded.
Backend() Backend
}
// activeBackend is set by the init() in bridgevm_gpu.go (cgo) or
// bridgevm_gpu_nocgo.go (!cgo). Read via AutoBackend(). Stored as atomic
// uint32 so concurrent reads in vm.go (the round applier picks the backend
// per call) 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 — every GPUBackend method
// returns ErrGPUNotAvailable; callers should route to the CPU oracle.
//
// The probe runs once at init time; this getter is cheap (single atomic
// load) and safe to call from any goroutine. Use ActiveGPUBackend() to
// get an invocable GPUBackend handle.
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).
func setActiveBackend(b Backend) {
activeBackend.Store(uint32(uint8(b)))
}
// =============================================================================
// Sizeof asserts — fail fast at process start if any layout struct above
// drifts from the GPU header. A drift here would silently corrupt every
// round's state root, so a panic at startup is the correct behaviour.
// =============================================================================
func init() {
// Each entry is (name, got, want) so the panic message names the
// offending struct instead of just "drift".
type szCheck struct {
name string
got uintptr
want uintptr
}
checks := []szCheck{
{"Signer", unsafe.Sizeof(Signer{}), 208},
{"LiquidityEntry", unsafe.Sizeof(LiquidityEntry{}), 80},
{"DailyLimit", unsafe.Sizeof(DailyLimit{}), 64},
{"Message", unsafe.Sizeof(Message{}), 240},
{"BridgeVMEpochState", unsafe.Sizeof(BridgeVMEpochState{}), 240},
{"BridgeVMRoundDescriptor", unsafe.Sizeof(BridgeVMRoundDescriptor{}), 112},
{"SignerOp", unsafe.Sizeof(SignerOp{}), 224},
{"LiquidityOp", unsafe.Sizeof(LiquidityOp{}), 64},
{"OutboundReq", unsafe.Sizeof(OutboundReq{}), 112},
{"BridgeVMTransitionResult", unsafe.Sizeof(BridgeVMTransitionResult{}), 304},
}
for _, c := range checks {
if c.got != c.want {
panic(fmt.Sprintf(
"bridgevm: %s layout drift — Go sizeof=%d, GPU header expects %d. "+
"Update backend.go to match ops/bridgevm/cuda/bridgevm_kernels_common.cuh.",
c.name, c.got, c.want))
}
}
}
+561
View File
@@ -0,0 +1,561 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo
package bridgevm
// CGo-backed BridgeVM GPU substrate. Loads the per-backend plugin
// libluxgpu_backend_<bk>.{so,dylib} at process start via dlopen(3) and
// resolves the five lux_<bk>_bridgevm_* host launcher symbols via dlsym(3).
//
// Direct dlopen — NOT pkg-config. The plugin lives in
// ~/work/lux-private/gpu-kernels (a private repo); the public chains module
// must build without it present. Linking via pkg-config would couple the
// public build to a private dep; dlopen + dlsym lets us probe at runtime
// and degrade cleanly to ErrGPUNotAvailable when the plugin is absent.
//
// Search path for the plugin:
//
// 1. $LUX_GPU_PLUGIN_DIR (explicit override, useful in tests / CI).
// 2. $LUXCPP_PREFIX/lib/lux-gpu/ (the install tree).
// 3. $LUXCPP_PREFIX/lib/ (legacy install tree).
// 4. $HOME/work/lux-private/gpu-kernels/build/backends/<bk>/ (dev tree).
// 5. The current working directory.
// 6. The system loader's default path (dlopen with bare name).
//
// Probe order — cuda → hip → metal → vulkan → webgpu. The first plugin that
// resolves all five symbols wins and sets activeBackend. Subsequent probes
// are skipped (one and only one plugin loaded per process).
//
// Thread safety: the init() probe runs once before main(). Post-init, the
// pluginHandle and the five fnPtr_* package vars are read-only — concurrent
// SignerApply / LiquidityApply / etc. calls from many goroutines are safe.
// Each launcher allocates its own scratch buffers; there are no shared
// mutables on the Go side. The C launchers themselves are documented as
// concurrent-safe (per backends/<bk>/src/bridgevm_launchers.{mm,cpp}).
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
// Trampolines — we cannot call function pointers from Go directly through cgo.
// The trampoline takes the function pointer (resolved via dlsym from Go) and
// the launcher args and calls the launcher. One trampoline per launcher
// signature.
typedef int (*bvm_signer_fn)(
const void* desc, const void* ops, void* signers, void* applied_out,
uint32_t signer_count, void* stream);
typedef int (*bvm_liquidity_fn)(
const void* desc, const void* ops, void* liquidity, void* applied_out,
void* total_fees_lo_out, void* total_fees_hi_out,
uint32_t liquidity_count, void* stream);
typedef int (*bvm_inbox_fn)(
const void* desc, const void* in_msgs, void* signers, void* daily,
void* inbox, void* applied_out, void* total_in_lo_out, void* total_in_hi_out,
uint32_t signer_count, uint32_t daily_count, uint32_t inbox_count,
void* stream);
typedef int (*bvm_outbox_fn)(
const void* desc, const void* reqs, void* daily, void* outbox, void* epoch,
void* applied_out, void* total_out_lo_out, void* total_out_hi_out,
uint32_t daily_count, uint32_t outbox_count, void* stream);
typedef int (*bvm_transition_fn)(
const void* desc, void* signers, void* liquidity, void* daily,
void* inbox, void* outbox, void* epoch, void* result,
uint32_t signer_count, uint32_t liquidity_count, uint32_t daily_count,
uint32_t inbox_count, uint32_t outbox_count,
void* stream);
static int call_signer(void* fn,
const void* desc, const void* ops, void* signers, void* applied_out,
uint32_t signer_count) {
return ((bvm_signer_fn)fn)(desc, ops, signers, applied_out,
signer_count, (void*)0);
}
static int call_liquidity(void* fn,
const void* desc, const void* ops, void* liquidity, void* applied_out,
void* total_fees_lo_out, void* total_fees_hi_out,
uint32_t liquidity_count) {
return ((bvm_liquidity_fn)fn)(desc, ops, liquidity, applied_out,
total_fees_lo_out, total_fees_hi_out,
liquidity_count, (void*)0);
}
static int call_inbox(void* fn,
const void* desc, const void* in_msgs, void* signers, void* daily,
void* inbox, void* applied_out, void* total_in_lo_out, void* total_in_hi_out,
uint32_t signer_count, uint32_t daily_count, uint32_t inbox_count) {
return ((bvm_inbox_fn)fn)(desc, in_msgs, signers, daily, inbox,
applied_out, total_in_lo_out, total_in_hi_out,
signer_count, daily_count, inbox_count,
(void*)0);
}
static int call_outbox(void* fn,
const void* desc, const void* reqs, void* daily, void* outbox, void* epoch,
void* applied_out, void* total_out_lo_out, void* total_out_hi_out,
uint32_t daily_count, uint32_t outbox_count) {
return ((bvm_outbox_fn)fn)(desc, reqs, daily, outbox, epoch,
applied_out, total_out_lo_out, total_out_hi_out,
daily_count, outbox_count, (void*)0);
}
static int call_transition(void* fn,
const void* desc, void* signers, void* liquidity, void* daily,
void* inbox, void* outbox, void* epoch, void* result,
uint32_t signer_count, uint32_t liquidity_count, uint32_t daily_count,
uint32_t inbox_count, uint32_t outbox_count) {
return ((bvm_transition_fn)fn)(desc, signers, liquidity, daily,
inbox, outbox, epoch, result,
signer_count, liquidity_count,
daily_count, inbox_count, outbox_count,
(void*)0);
}
// dl helpers — wrap dlopen / dlsym / dlerror so the Go side doesn't have to
// deal with the C string lifetimes.
static void* dl_open(const char* path) {
return dlopen(path, RTLD_NOW | RTLD_GLOBAL);
}
static void* dl_sym(void* h, const char* name) {
return dlsym(h, name);
}
static const char* dl_err(void) {
const char* e = dlerror();
return e ? e : "";
}
*/
import "C"
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"unsafe"
)
// pluginHandle is the dlopen()'d handle to libluxgpu_backend_<bk>.{so,dylib}.
// Held for the lifetime of the process (we never dlclose — the plugin's
// runtime state, GPU contexts, kernel caches, etc., would all be invalidated
// and any in-flight goroutine call would crash).
var pluginHandle unsafe.Pointer
// fnPtr_<launcher> are the dlsym'd entry points. Read-only after init(); a
// nil entry means the loaded plugin doesn't export that symbol (which fails
// the probe — we require all five).
var (
fnSignerApply unsafe.Pointer
fnLiquidityApply unsafe.Pointer
fnMessageInbox unsafe.Pointer
fnMessageOutbox unsafe.Pointer
fnBridgeTransit unsafe.Pointer
)
// initOnce guards the probe — even though init() runs at most once, this
// belt-and-braces guard makes the contract explicit and lets the test files
// re-trigger the probe deterministically (via a debug entry, not yet wired).
var initOnce sync.Once
func init() {
initOnce.Do(probePlugin)
}
// probePlugin walks the dlopen search path × backend probe order looking
// for the first libluxgpu_backend_<bk>.{so,dylib} that exposes all five
// lux_<bk>_bridgevm_* launchers. First match wins; activeBackend is set
// and pluginHandle + fnPtr_* are populated.
//
// On no match: activeBackend stays at BackendNone, every GPUBackend method
// returns ErrGPUNotAvailable. We do NOT log here — process start in the
// chains binary is noisy enough; vm.go (the consumer) can log AutoBackend()
// at the level it wants.
func probePlugin() {
for _, bk := range []Backend{
BackendCUDA, BackendHIP, BackendMetal, BackendVulkan, BackendWebGPU,
} {
for _, path := range candidatePluginPaths(bk) {
if !plausiblePath(path) {
continue
}
if !tryLoad(bk, path) {
continue
}
setActiveBackend(bk)
return
}
// Bare-name fallback — let the dynamic loader use its default path.
if tryLoad(bk, dsoBareName(bk)) {
setActiveBackend(bk)
return
}
}
}
// candidatePluginPaths returns the search list for one backend, in priority
// order. The list is small (≤6) so we don't bother deduping — a duplicate
// dlopen on a path that doesn't exist is cheap (immediate ENOENT).
func candidatePluginPaths(bk Backend) []string {
name := dsoBareName(bk)
var paths []string
// 1) Explicit override — useful in tests / CI to pin a specific build.
if env := os.Getenv("LUX_GPU_PLUGIN_DIR"); env != "" {
paths = append(paths, filepath.Join(env, name))
paths = append(paths, filepath.Join(env, bk.String(), name))
}
// 2/3) Install tree (set by `cmake --install`).
if prefix := os.Getenv("LUXCPP_PREFIX"); prefix != "" {
paths = append(paths, filepath.Join(prefix, "lib", "lux-gpu", name))
paths = append(paths, filepath.Join(prefix, "lib", name))
}
// 4) Dev tree — the canonical layout from gpu-kernels' build/.
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths,
filepath.Join(home, "work", "lux-private", "gpu-kernels",
"build", "backends", bk.String(), name))
}
// 5) CWD — last resort before falling back to the loader default.
if cwd, err := os.Getwd(); err == nil {
paths = append(paths, filepath.Join(cwd, name))
}
return paths
}
// dsoBareName returns the platform-correct shared object name for a backend.
// macOS uses .dylib; everything else (linux, *bsd, windows) uses .so —
// Windows builds are produced from the WSL/MinGW toolchain in the gpu-kernels
// CI matrix and ship .so as well per the gpu-kernels CMake convention.
func dsoBareName(bk Backend) string {
ext := ".so"
if runtime.GOOS == "darwin" {
ext = ".dylib"
}
return "libluxgpu_backend_" + bk.String() + ext
}
// plausiblePath returns false for an absolute path that doesn't exist, so we
// skip wasted dlopen calls (each one allocates inside libc's dlerror buffer).
// Bare names (without a slash) are always "plausible" — the loader handles
// the search.
func plausiblePath(p string) bool {
if filepath.IsAbs(p) {
_, err := os.Stat(p)
return err == nil
}
return true
}
// tryLoad dlopens `path`, then dlsyms the five lux_<bk>_bridgevm_* symbols.
// All-or-nothing — if any symbol is missing the handle is left open (it's
// global and the next backend's dlsym calls may benefit) but pluginHandle
// stays nil. Returns true iff every symbol resolved and we've committed to
// this backend.
func tryLoad(bk Backend, path string) bool {
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
// Clear any prior dlerror state so dl_err() reports THIS call's error.
C.dl_err()
h := C.dl_open(cpath)
if h == nil {
return false
}
prefix := "lux_" + bk.String() + "_bridgevm_"
syms := map[string]*unsafe.Pointer{
prefix + "signer_apply": &fnSignerApply,
prefix + "liquidity_apply": &fnLiquidityApply,
prefix + "message_inbox": &fnMessageInbox,
prefix + "message_outbox": &fnMessageOutbox,
prefix + "transition": &fnBridgeTransit,
}
staging := make(map[string]unsafe.Pointer, 5)
for name := range syms {
cname := C.CString(name)
p := C.dl_sym(h, cname)
C.free(unsafe.Pointer(cname))
if p == nil {
// Missing symbol — backend's plugin doesn't expose bridgevm.
// Don't dlclose: the same .so may carry useful symbols for the
// next probe. Yes this leaks a handle on miss, but probes are
// bounded (5 max) and run once at init.
return false
}
staging[name] = unsafe.Pointer(p)
}
// All five resolved — commit.
pluginHandle = unsafe.Pointer(h)
for name, slot := range syms {
*slot = staging[name]
}
return true
}
// =============================================================================
// GPUBackend implementation — one struct, one method per launcher. The struct
// is stateless (just a Backend tag); the real state lives in the package-level
// fnPtr_* and pluginHandle variables. We expose it as an interface (not a
// concrete struct) so the !cgo stub can satisfy the same surface.
// =============================================================================
// ActiveGPUBackend returns an invocable GPUBackend. When no plugin is loaded
// the returned backend's methods all return ErrGPUNotAvailable; the caller
// can route to the CPU oracle. Always non-nil — there is no error path here.
func ActiveGPUBackend() GPUBackend {
return cgoBackend{tag: AutoBackend()}
}
type cgoBackend struct {
tag Backend
}
func (b cgoBackend) Backend() Backend { return b.tag }
// errFromCode wraps a non-zero launcher return as a Go error tagged with the
// op name + numeric status. The launcher contract is `0 = success`,
// non-zero = error code with meanings documented per-launcher (1 = null
// pointer, 2 = device unavailable, 3 = pipeline compile failure, etc.).
func errFromCode(op string, code C.int) error {
if code == 0 {
return nil
}
return fmt.Errorf("bridgevm: %s launcher returned code %d", op, int(code))
}
// guardOrErr returns ErrGPUNotAvailable when the plugin isn't loaded.
// Centralised so each method has the same gate without scattering branches.
func (b cgoBackend) guard(fn unsafe.Pointer) error {
if pluginHandle == nil || fn == nil || b.tag == BackendNone {
return ErrGPUNotAvailable
}
return nil
}
func (b cgoBackend) SignerApply(
desc *BridgeVMRoundDescriptor,
ops []SignerOp,
signers []Signer,
) (uint32, error) {
if err := b.guard(fnSignerApply); err != nil {
return 0, err
}
if desc == nil || len(signers) == 0 {
return 0, fmt.Errorf("bridgevm: SignerApply requires non-nil desc + non-empty signers")
}
var applied uint32
descP := unsafe.Pointer(desc)
var opsP unsafe.Pointer
if len(ops) > 0 {
opsP = unsafe.Pointer(&ops[0])
}
signersP := unsafe.Pointer(&signers[0])
appliedP := unsafe.Pointer(&applied)
code := C.call_signer(
fnSignerApply,
descP, opsP, signersP, appliedP,
C.uint32_t(len(signers)),
)
runtime.KeepAlive(desc)
runtime.KeepAlive(ops)
runtime.KeepAlive(signers)
return applied, errFromCode("signer_apply", code)
}
func (b cgoBackend) LiquidityApply(
desc *BridgeVMRoundDescriptor,
ops []LiquidityOp,
liquidity []LiquidityEntry,
) (uint32, uint64, uint64, error) {
if err := b.guard(fnLiquidityApply); err != nil {
return 0, 0, 0, err
}
if desc == nil || len(liquidity) == 0 {
return 0, 0, 0, fmt.Errorf(
"bridgevm: LiquidityApply requires non-nil desc + non-empty liquidity")
}
var (
applied uint32
totalFeesLo uint64
totalFeesHi uint64
)
var opsP unsafe.Pointer
if len(ops) > 0 {
opsP = unsafe.Pointer(&ops[0])
}
code := C.call_liquidity(
fnLiquidityApply,
unsafe.Pointer(desc),
opsP,
unsafe.Pointer(&liquidity[0]),
unsafe.Pointer(&applied),
unsafe.Pointer(&totalFeesLo),
unsafe.Pointer(&totalFeesHi),
C.uint32_t(len(liquidity)),
)
runtime.KeepAlive(desc)
runtime.KeepAlive(ops)
runtime.KeepAlive(liquidity)
return applied, totalFeesLo, totalFeesHi, errFromCode("liquidity_apply", code)
}
func (b cgoBackend) MessageInbox(
desc *BridgeVMRoundDescriptor,
inMsgs []Message,
signers []Signer,
daily []DailyLimit,
inbox []Message,
) (uint32, uint64, uint64, error) {
if err := b.guard(fnMessageInbox); err != nil {
return 0, 0, 0, err
}
if desc == nil || len(signers) == 0 || len(daily) == 0 || len(inbox) == 0 {
return 0, 0, 0, fmt.Errorf(
"bridgevm: MessageInbox requires non-nil desc + non-empty signers/daily/inbox")
}
var (
applied uint32
totalInLo uint64
totalInHi uint64
)
var inMsgsP unsafe.Pointer
if len(inMsgs) > 0 {
inMsgsP = unsafe.Pointer(&inMsgs[0])
}
code := C.call_inbox(
fnMessageInbox,
unsafe.Pointer(desc),
inMsgsP,
unsafe.Pointer(&signers[0]),
unsafe.Pointer(&daily[0]),
unsafe.Pointer(&inbox[0]),
unsafe.Pointer(&applied),
unsafe.Pointer(&totalInLo),
unsafe.Pointer(&totalInHi),
C.uint32_t(len(signers)),
C.uint32_t(len(daily)),
C.uint32_t(len(inbox)),
)
runtime.KeepAlive(desc)
runtime.KeepAlive(inMsgs)
runtime.KeepAlive(signers)
runtime.KeepAlive(daily)
runtime.KeepAlive(inbox)
return applied, totalInLo, totalInHi, errFromCode("message_inbox", code)
}
func (b cgoBackend) MessageOutbox(
desc *BridgeVMRoundDescriptor,
reqs []OutboundReq,
daily []DailyLimit,
outbox []Message,
epoch *BridgeVMEpochState,
) (uint32, uint64, uint64, error) {
if err := b.guard(fnMessageOutbox); err != nil {
return 0, 0, 0, err
}
if desc == nil || epoch == nil || len(daily) == 0 || len(outbox) == 0 {
return 0, 0, 0, fmt.Errorf(
"bridgevm: MessageOutbox requires non-nil desc/epoch + non-empty daily/outbox")
}
var (
applied uint32
totalOutLo uint64
totalOutHi uint64
)
var reqsP unsafe.Pointer
if len(reqs) > 0 {
reqsP = unsafe.Pointer(&reqs[0])
}
code := C.call_outbox(
fnMessageOutbox,
unsafe.Pointer(desc),
reqsP,
unsafe.Pointer(&daily[0]),
unsafe.Pointer(&outbox[0]),
unsafe.Pointer(epoch),
unsafe.Pointer(&applied),
unsafe.Pointer(&totalOutLo),
unsafe.Pointer(&totalOutHi),
C.uint32_t(len(daily)),
C.uint32_t(len(outbox)),
)
runtime.KeepAlive(desc)
runtime.KeepAlive(reqs)
runtime.KeepAlive(daily)
runtime.KeepAlive(outbox)
runtime.KeepAlive(epoch)
return applied, totalOutLo, totalOutHi, errFromCode("message_outbox", code)
}
func (b cgoBackend) BridgeTransition(
desc *BridgeVMRoundDescriptor,
signers []Signer,
liquidity []LiquidityEntry,
daily []DailyLimit,
inbox []Message,
outbox []Message,
epoch *BridgeVMEpochState,
result *BridgeVMTransitionResult,
) error {
if err := b.guard(fnBridgeTransit); err != nil {
return err
}
if desc == nil || epoch == nil || result == nil ||
len(signers) == 0 || len(liquidity) == 0 || len(daily) == 0 ||
len(inbox) == 0 || len(outbox) == 0 {
return fmt.Errorf(
"bridgevm: BridgeTransition requires non-nil desc/epoch/result + non-empty arrays")
}
code := C.call_transition(
fnBridgeTransit,
unsafe.Pointer(desc),
unsafe.Pointer(&signers[0]),
unsafe.Pointer(&liquidity[0]),
unsafe.Pointer(&daily[0]),
unsafe.Pointer(&inbox[0]),
unsafe.Pointer(&outbox[0]),
unsafe.Pointer(epoch),
unsafe.Pointer(result),
C.uint32_t(len(signers)),
C.uint32_t(len(liquidity)),
C.uint32_t(len(daily)),
C.uint32_t(len(inbox)),
C.uint32_t(len(outbox)),
)
runtime.KeepAlive(desc)
runtime.KeepAlive(signers)
runtime.KeepAlive(liquidity)
runtime.KeepAlive(daily)
runtime.KeepAlive(inbox)
runtime.KeepAlive(outbox)
runtime.KeepAlive(epoch)
runtime.KeepAlive(result)
return errFromCode("transition", code)
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !cgo
package bridgevm
// Non-cgo build of the BridgeVM GPU substrate. With CGO_ENABLED=0 we can't
// dlopen the plugin, so every GPUBackend method returns ErrGPUNotAvailable.
// The package-level AutoBackend() therefore reports BackendNone, and
// consumers (vm.go's round applier) should route to the CPU oracle.
//
// init() sets activeBackend to BackendNone explicitly — this mirrors the
// behaviour of the cgo build when the probe finds no plugin, so a caller's
// `if AutoBackend() == BackendNone { ... cpu fallback ... }` works
// identically under both build modes.
func init() {
setActiveBackend(BackendNone)
}
// ActiveGPUBackend returns a stub GPUBackend whose methods all return
// ErrGPUNotAvailable. Always non-nil so callers can dispatch without a nil
// check — they only need to handle the ErrGPUNotAvailable sentinel from
// the call sites.
func ActiveGPUBackend() GPUBackend { return nocgoBackend{} }
type nocgoBackend struct{}
func (nocgoBackend) Backend() Backend { return BackendNone }
func (nocgoBackend) SignerApply(
_ *BridgeVMRoundDescriptor,
_ []SignerOp,
_ []Signer,
) (uint32, error) {
return 0, ErrGPUNotAvailable
}
func (nocgoBackend) LiquidityApply(
_ *BridgeVMRoundDescriptor,
_ []LiquidityOp,
_ []LiquidityEntry,
) (uint32, uint64, uint64, error) {
return 0, 0, 0, ErrGPUNotAvailable
}
func (nocgoBackend) MessageInbox(
_ *BridgeVMRoundDescriptor,
_ []Message,
_ []Signer,
_ []DailyLimit,
_ []Message,
) (uint32, uint64, uint64, error) {
return 0, 0, 0, ErrGPUNotAvailable
}
func (nocgoBackend) MessageOutbox(
_ *BridgeVMRoundDescriptor,
_ []OutboundReq,
_ []DailyLimit,
_ []Message,
_ *BridgeVMEpochState,
) (uint32, uint64, uint64, error) {
return 0, 0, 0, ErrGPUNotAvailable
}
func (nocgoBackend) BridgeTransition(
_ *BridgeVMRoundDescriptor,
_ []Signer,
_ []LiquidityEntry,
_ []DailyLimit,
_ []Message,
_ []Message,
_ *BridgeVMEpochState,
_ *BridgeVMTransitionResult,
) error {
return ErrGPUNotAvailable
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo
package bridgevm
// Round-trip test for the dlopen + dlsym BridgeVM GPU substrate.
//
// We do not require a plugin to be present — the test is structured to SKIP
// cleanly when AutoBackend() == BackendNone (no libluxgpu_backend_*.so on
// the search path), which is the common case in a public chains checkout.
//
// When a plugin IS present (e.g. dev box with ~/work/lux-private/gpu-kernels
// built and $LUX_GPU_PLUGIN_DIR set to the build/backends/<bk>/ dir), the
// test exercises signer_apply with a zero descriptor + zero ops + a sized
// signers arena and asserts:
//
// 1. The launcher returns code 0 (success) — the zero descriptor has
// signer_op_count=0 so the kernel walks no ops and writes applied=0
// without touching the signers arena. This is the smallest non-trivial
// round-trip that exercises dlopen → dlsym → cgo call → kernel dispatch
// → C ABI return → Go slice readback.
// 2. applied == 0 — the zero ops pile shouldn't apply anything.
// 3. The signers arena is unchanged (sentinel-byte check on a single slot).
import (
"errors"
"os"
"testing"
"unsafe"
)
// TestActiveBackend just verifies AutoBackend() is callable and returns
// either BackendNone (no plugin) or a known backend tag. Always runs.
func TestActiveBackend(t *testing.T) {
bk := AutoBackend()
switch bk {
case BackendNone, BackendCUDA, BackendHIP, BackendMetal, BackendVulkan, BackendWebGPU:
t.Logf("AutoBackend() = %s", bk)
default:
t.Fatalf("unexpected backend: %v", bk)
}
}
// TestStubReturnsErrGPUNotAvailable asserts the contract: when the plugin
// isn't loaded, every method returns ErrGPUNotAvailable. Constructed
// directly with tag=BackendNone so the test works regardless of whether a
// plugin happens to be on the search path.
func TestStubReturnsErrGPUNotAvailable(t *testing.T) {
b := cgoBackend{tag: BackendNone}
if _, err := b.SignerApply(&BridgeVMRoundDescriptor{}, nil,
make([]Signer, 1)); !errors.Is(err, ErrGPUNotAvailable) {
t.Errorf("SignerApply: got %v, want ErrGPUNotAvailable", err)
}
if _, _, _, err := b.LiquidityApply(&BridgeVMRoundDescriptor{}, nil,
make([]LiquidityEntry, 1)); !errors.Is(err, ErrGPUNotAvailable) {
t.Errorf("LiquidityApply: got %v, want ErrGPUNotAvailable", err)
}
if _, _, _, err := b.MessageInbox(&BridgeVMRoundDescriptor{}, nil,
make([]Signer, 1), make([]DailyLimit, 1), make([]Message, 1)); !errors.Is(err, ErrGPUNotAvailable) {
t.Errorf("MessageInbox: got %v, want ErrGPUNotAvailable", err)
}
if _, _, _, err := b.MessageOutbox(&BridgeVMRoundDescriptor{}, nil,
make([]DailyLimit, 1), make([]Message, 1),
&BridgeVMEpochState{}); !errors.Is(err, ErrGPUNotAvailable) {
t.Errorf("MessageOutbox: got %v, want ErrGPUNotAvailable", err)
}
if err := b.BridgeTransition(&BridgeVMRoundDescriptor{},
make([]Signer, 1), make([]LiquidityEntry, 1), make([]DailyLimit, 1),
make([]Message, 1), make([]Message, 1),
&BridgeVMEpochState{}, &BridgeVMTransitionResult{}); !errors.Is(err, ErrGPUNotAvailable) {
t.Errorf("BridgeTransition: got %v, want ErrGPUNotAvailable", err)
}
}
// TestLayoutSizesMatchHeader is the runtime-equivalent of the C++
// static_asserts in ops/bridgevm/cuda/bridgevm_kernels_common.cuh. The init()
// in backend.go already panics on drift; this test makes the assertions
// visible to `go test -v` output for CI dashboards and named per-struct so
// a diff narrows immediately to the offending type.
func TestLayoutSizesMatchHeader(t *testing.T) {
cases := []struct {
name string
got uintptr
want uintptr
}{
{"Signer", sizeOf[Signer](), 208},
{"LiquidityEntry", sizeOf[LiquidityEntry](), 80},
{"DailyLimit", sizeOf[DailyLimit](), 64},
{"Message", sizeOf[Message](), 240},
{"BridgeVMEpochState", sizeOf[BridgeVMEpochState](), 240},
{"BridgeVMRoundDescriptor", sizeOf[BridgeVMRoundDescriptor](), 112},
{"SignerOp", sizeOf[SignerOp](), 224},
{"LiquidityOp", sizeOf[LiquidityOp](), 64},
{"OutboundReq", sizeOf[OutboundReq](), 112},
{"BridgeVMTransitionResult", sizeOf[BridgeVMTransitionResult](), 304},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s: sizeof=%d, want %d", c.name, c.got, c.want)
}
}
}
// TestSignerApplyZeroFixture is the round-trip we promised: dlopen, dlsym,
// call lux_<best>_bridgevm_signer_apply with a zero-initialised descriptor +
// zero ops + sized signers arena, assert no error. SKIPs when no plugin is
// loaded so the test passes in a public checkout without gpu-kernels built.
func TestSignerApplyZeroFixture(t *testing.T) {
bk := AutoBackend()
if bk == BackendNone {
t.Skip("no GPU plugin loaded; set LUX_GPU_PLUGIN_DIR=" +
os.Getenv("HOME") + "/work/lux-private/gpu-kernels/build/backends/<bk> to exercise")
}
// Zero descriptor → signer_op_count=0, kernel walks no ops. The mode
// field stays at kModeInbox (0) which is what the kernel checks
// to early-out from anything other than signer_apply when it's not
// the requested mode — but signer_apply ignores mode (it's the
// dispatcher's job, not the kernel's, per bridgevm_signer.cu).
desc := BridgeVMRoundDescriptor{}
// Sized arena, byte-zero. Sentinel: set occupied=0 explicitly so we
// can read it back unchanged.
signers := make([]Signer, 16)
for i := range signers {
signers[i].Occupied = 0
}
// Sized but zero-init ops slice. The descriptor says signer_op_count=0
// so the kernel walks none of them, but launchers (Vulkan in particular)
// require ops to be non-nil — they pass it through as a storage-buffer
// binding even when bytes_ops is zero. One element is enough to give
// the launcher a valid pointer to bind without committing the caller
// to any non-trivial work.
ops := make([]SignerOp, 1)
b := ActiveGPUBackend()
applied, err := b.SignerApply(&desc, ops, signers)
if err != nil {
t.Fatalf("SignerApply(zero fixture) on %s: %v", bk, err)
}
if applied != 0 {
t.Errorf("SignerApply(zero fixture): applied=%d, want 0", applied)
}
// Arena should be unchanged.
for i, s := range signers {
if s.Occupied != 0 {
t.Errorf("signers[%d].Occupied = %d, want 0 (kernel touched the arena on no-op input)",
i, s.Occupied)
}
}
}
// sizeOf is a tiny generic helper to make the layout-asserts read naturally
// in TestLayoutSizesMatchHeader. Keeping unsafe.Sizeof out of the test
// callsites makes each line a one-liner; the unsafe is centralised here.
func sizeOf[T any]() uintptr {
var z T
return unsafe.Sizeof(z)
}