mirror of
https://github.com/luxfi/chains.git
synced 2026-07-27 03:39:41 +00:00
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user