mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
dexvm: wire GPU plugin substrate via dlopen+dlsym
Adds four Go files to chains/dexvm so the DEX VM can call into the
lux-private/gpu-kernels plugin DEX launchers (AMM xyk + CLOB matcher +
BookArena lifecycle) without a build-time pkg-config dependency:
* backend.go cross-build types: GPUBackend enum, LuxAmmReservePair
(16 bytes, byte-equal to include/lux/gpu/dex.h),
CLOB ABI constants, ErrGPUNotAvailable.
* dexvm_gpu.go (cgo) dlopen probe at init() in order
cuda -> hip -> metal -> vulkan -> webgpu, resolving
lux_<backend>_{amm_xyk_batch,dex_clob_match,
dex_clob_arena_create,dex_clob_arena_destroy}.
Exposes AutoBackend / AMMSwap / CLOBMatch /
ArenaCreate / ArenaDestroy. unsafe.Sizeof guards
the AMM struct layout against silent ABI drift.
* dexvm_gpu_nocgo.go symmetric stubs returning ErrGPUNotAvailable so
the package surface is identical under both tags.
* dexvm_gpu_test.go round-trip test: dlopens the metal plugin (or any
other found under LUX_GPU_PLUGIN_DIR), runs a
single-pair AMM swap, asserts byte-equal output
to the CPU oracle (constant-product 1000 -> 1998
at rx=1M, ry=2M).
Existing dexvm vm.go / orderbook logic is untouched - consumers opt in
explicitly when ready. Both 'go build ./dexvm/...' and
'go build -tags cgo ./dexvm/...' are green.
Test output with LUX_GPU_PLUGIN_DIR=$HOME/work/lux-private/gpu-kernels/build-metal/backends/metal:
--- PASS: TestGPUAMMSwapRoundTrip (0.08s) [backend=metal]
--- PASS: TestGPUAMMSwapEmpty (0.00s)
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// GPU plugin bridge — shared types between cgo and nocgo builds.
|
||||
//
|
||||
// The dexvm GPU backend is loaded via dlopen at process start: the
|
||||
// init() in dexvm_gpu.go probes a fixed candidate list of plugin
|
||||
// dylibs in order (cuda → hip → metal → vulkan → webgpu) and binds
|
||||
// the host launcher symbols exported by the gpu-kernels plugin:
|
||||
//
|
||||
// lux_<backend>_amm_xyk_batch (AMM xyk swap)
|
||||
// lux_<backend>_dex_clob_match (CLOB matcher, EVM 0x100)
|
||||
// lux_<backend>_dex_clob_arena_create (BookArena lifecycle)
|
||||
// lux_<backend>_dex_clob_arena_destroy
|
||||
//
|
||||
// The plugin sources live at:
|
||||
// ~/work/lux-private/gpu-kernels/backends/<X>/src/dex_launchers.{mm,cpp}
|
||||
// ~/work/lux-private/gpu-kernels/ops/dex/<X>/...
|
||||
//
|
||||
// Public ABI header (single source of truth for byte layouts):
|
||||
// ~/work/lux-private/gpu-kernels/include/lux/gpu/dex.h
|
||||
//
|
||||
// The cgo file (dexvm_gpu.go) does the dlopen + dlsym. The nocgo file
|
||||
// (dexvm_gpu_nocgo.go) is a thin stub returning ErrGPUNotAvailable.
|
||||
// This file holds the cross-build types every consumer can reference
|
||||
// without taking a cgo dependency.
|
||||
//
|
||||
// IMPORTANT: this is opt-in. The existing dexvm vm.go and orderbook
|
||||
// path do NOT call into this backend. Helpers here are exposed so
|
||||
// consumers can adopt the GPU path explicitly when ready.
|
||||
|
||||
package dexvm
|
||||
|
||||
import "errors"
|
||||
|
||||
// GPUBackend names the GPU backend the plugin loader bound. The
|
||||
// numeric values match the dlopen probe order used by init() —
|
||||
// callers can rely on (GPUBackendCUDA < GPUBackendHIP < GPUBackendMetal
|
||||
// < GPUBackendVulkan < GPUBackendWebGPU) as a stable ordering of
|
||||
// preference.
|
||||
type GPUBackend uint8
|
||||
|
||||
const (
|
||||
// GPUBackendNone means no plugin was loaded (or build was !cgo).
|
||||
GPUBackendNone GPUBackend = iota
|
||||
// GPUBackendCUDA is the NVIDIA CUDA plugin (libluxgpu_backend_cuda.so).
|
||||
GPUBackendCUDA
|
||||
// GPUBackendHIP is the AMD ROCm/HIP plugin (libluxgpu_backend_hip.so).
|
||||
GPUBackendHIP
|
||||
// GPUBackendMetal is the Apple Metal plugin (libluxgpu_backend_metal.dylib).
|
||||
GPUBackendMetal
|
||||
// GPUBackendVulkan is the Vulkan plugin (libluxgpu_backend_vulkan.{so,dylib,dll}).
|
||||
GPUBackendVulkan
|
||||
// GPUBackendWebGPU is the wgpu/Dawn plugin (libluxgpu_backend_webgpu.{so,dylib,dll}).
|
||||
GPUBackendWebGPU
|
||||
)
|
||||
|
||||
// String returns the human-readable lowercase backend name used in the
|
||||
// host launcher symbol prefix (e.g. "metal" → "lux_metal_amm_xyk_batch").
|
||||
func (b GPUBackend) String() string {
|
||||
switch b {
|
||||
case GPUBackendCUDA:
|
||||
return "cuda"
|
||||
case GPUBackendHIP:
|
||||
return "hip"
|
||||
case GPUBackendMetal:
|
||||
return "metal"
|
||||
case GPUBackendVulkan:
|
||||
return "vulkan"
|
||||
case GPUBackendWebGPU:
|
||||
return "webgpu"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
// ErrGPUNotAvailable is returned by every GPU method on the nocgo
|
||||
// build, and by the cgo build when init() failed to bind any plugin.
|
||||
// Consumers treat this as "fall back to the CPU path" rather than as
|
||||
// a hard failure.
|
||||
var ErrGPUNotAvailable = errors.New("dexvm: GPU plugin not available")
|
||||
|
||||
// LuxAmmReservePair mirrors include/lux/gpu/dex.h::LuxAmmReservePair.
|
||||
// 16 bytes, packed (no padding), little-endian uint64 fields.
|
||||
// MUST match:
|
||||
// ~/work/lux-private/gpu-kernels/include/lux/gpu/dex.h::LuxAmmReservePair
|
||||
// ~/work/lux/dex/pkg/lx/amm.go::ReservePair
|
||||
// ~/work/lux-private/gpu-kernels/ops/dex/{cuda,metal,wgsl}/amm_xyk*
|
||||
type LuxAmmReservePair struct {
|
||||
ReserveX uint64
|
||||
ReserveY uint64
|
||||
}
|
||||
|
||||
// CLOB ABI byte sizes from include/lux/gpu/dex.h.
|
||||
const (
|
||||
// LuxCLOBCalldataLen is the on-wire calldata for EVM precompile 0x100:
|
||||
// side(1) | price(32 BE) | qty(32 BE) | user(20) | book_id(32) = 117.
|
||||
LuxCLOBCalldataLen = 117
|
||||
// LuxCLOBOutLen is the output: filled(32 BE) | avg_price(32 BE) | num_fills(4 BE) = 68.
|
||||
LuxCLOBOutLen = 68
|
||||
// LuxCLOBMaxLevels caps the per-side resting-level depth (bid_*/ask_* arrays).
|
||||
LuxCLOBMaxLevels = 1024
|
||||
// LuxCLOBBookIDOffset is where book_id starts inside calldata.
|
||||
LuxCLOBBookIDOffset = 85
|
||||
// LuxCLOBBookIDLen is the book_id width.
|
||||
LuxCLOBBookIDLen = 32
|
||||
)
|
||||
@@ -0,0 +1,490 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build cgo
|
||||
|
||||
// dexvm_gpu.go — direct dlopen + dlsym to the gpu-kernels DEX plugin.
|
||||
//
|
||||
// Why dlopen, not pkg-config: the AMM/CLOB host launchers live in a
|
||||
// per-backend plugin (libluxgpu_backend_{cuda,hip,metal,vulkan,webgpu})
|
||||
// whose presence is platform-dependent — Apple has only Metal+Vulkan
|
||||
// (via MoltenVK), Linux has CUDA+Vulkan+WebGPU, ROCm only on a small
|
||||
// set of AMD hosts. pkg-config would force a build-time decision; we
|
||||
// want a runtime probe so the same go binary picks up whichever
|
||||
// plugin is on disk. This mirrors the dlopen pattern luxcpp/gpu uses
|
||||
// for its own backend loader (luxcpp/gpu/test/test_plugin_loader.cpp).
|
||||
//
|
||||
// Host launcher ABI — exposed by every plugin under the per-backend
|
||||
// symbol prefix `lux_<backend>_*`. The C signatures come straight from
|
||||
// ~/work/lux-private/gpu-kernels/backends/{metal/src/dex_launchers.mm,
|
||||
// vulkan/src/dex_launchers.cpp}:
|
||||
//
|
||||
// int amm_xyk_batch(const void* reserves, const void* amounts,
|
||||
// void* outs, uint32_t n, void* stream);
|
||||
// int dex_clob_match(void* arena, const uint8_t* calldata,
|
||||
// uint8_t* out, uint32_t* num_fills);
|
||||
// int dex_clob_arena_create(void** out_arena);
|
||||
// int dex_clob_arena_destroy(void* arena);
|
||||
//
|
||||
// All inputs are HOST pointers (no separate device address space is
|
||||
// exposed across the ABI — the launcher does H2D/D2H copies
|
||||
// internally). The `stream` argument on AMM is ignored by every
|
||||
// backend except CUDA/HIP (where it would be a cudaStream_t / hipStream_t).
|
||||
// `arena` for CLOB is opaque — created/destroyed via the symmetric
|
||||
// pair, persisted across calls so the BookArena lives device-resident
|
||||
// for the lifetime of the book.
|
||||
|
||||
package dexvm
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// Typed function-pointer typedefs for the four launcher symbols. cgo
|
||||
// can't cast `unsafe.Pointer` directly into a typed C function pointer,
|
||||
// so we route every call through a tiny trampoline that takes the
|
||||
// dlsym'd pointer and the typed arguments.
|
||||
|
||||
typedef int (*lux_amm_xyk_batch_fn)(
|
||||
const void* reserves,
|
||||
const void* amounts,
|
||||
void* outs,
|
||||
uint32_t n,
|
||||
void* stream);
|
||||
|
||||
typedef int (*lux_dex_clob_match_fn)(
|
||||
void* arena,
|
||||
const uint8_t* calldata,
|
||||
uint8_t* out,
|
||||
uint32_t* num_fills);
|
||||
|
||||
typedef int (*lux_dex_clob_arena_create_fn)(void** out_arena);
|
||||
typedef int (*lux_dex_clob_arena_destroy_fn)(void* arena);
|
||||
|
||||
static int call_amm_xyk_batch(
|
||||
void* fn,
|
||||
const void* reserves,
|
||||
const void* amounts,
|
||||
void* outs,
|
||||
uint32_t n)
|
||||
{
|
||||
return ((lux_amm_xyk_batch_fn)fn)(reserves, amounts, outs, n, NULL);
|
||||
}
|
||||
|
||||
static int call_dex_clob_match(
|
||||
void* fn,
|
||||
void* arena,
|
||||
const uint8_t* calldata,
|
||||
uint8_t* out,
|
||||
uint32_t* num_fills)
|
||||
{
|
||||
return ((lux_dex_clob_match_fn)fn)(arena, calldata, out, num_fills);
|
||||
}
|
||||
|
||||
static int call_dex_clob_arena_create(void* fn, void** out_arena) {
|
||||
return ((lux_dex_clob_arena_create_fn)fn)(out_arena);
|
||||
}
|
||||
|
||||
static int call_dex_clob_arena_destroy(void* fn, void* arena) {
|
||||
return ((lux_dex_clob_arena_destroy_fn)fn)(arena);
|
||||
}
|
||||
|
||||
// dlopen / dlsym wrappers. dlerror() returns thread-local NULL when
|
||||
// the last call succeeded; we call it after every failed lookup to
|
||||
// produce a meaningful error string back in Go.
|
||||
static void* lux_dlopen(const char* path) {
|
||||
return dlopen(path, RTLD_NOW | RTLD_LOCAL);
|
||||
}
|
||||
|
||||
static void lux_dlclose(void* handle) {
|
||||
if (handle) dlclose(handle);
|
||||
}
|
||||
|
||||
static void* lux_dlsym(void* handle, const char* name) {
|
||||
return dlsym(handle, name);
|
||||
}
|
||||
|
||||
static const char* lux_dlerror(void) {
|
||||
return dlerror();
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// gpuHandle bundles the dlopen handle and the four resolved symbol
|
||||
// pointers for one backend. Created by init() exactly once per
|
||||
// process; safe for concurrent use from multiple goroutines because
|
||||
// the underlying plugin uses thread-safe per-backend launchers (Metal
|
||||
// caches an MTLPipelineState behind a once-flag; Vulkan keeps a
|
||||
// process-global VkDevice + pool; CUDA/HIP launchers are reentrant).
|
||||
type gpuHandle struct {
|
||||
backend GPUBackend
|
||||
libPath string
|
||||
|
||||
hLib unsafe.Pointer // dlopen handle
|
||||
|
||||
fnAMMSwap unsafe.Pointer // lux_<backend>_amm_xyk_batch
|
||||
fnCLOBMatch unsafe.Pointer // lux_<backend>_dex_clob_match
|
||||
fnArenaCreate unsafe.Pointer // lux_<backend>_dex_clob_arena_create
|
||||
fnArenaDestroy unsafe.Pointer // lux_<backend>_dex_clob_arena_destroy
|
||||
}
|
||||
|
||||
// globalGPU holds the result of the init() probe. nil after a clean
|
||||
// init when no plugin was found on disk — every method returns
|
||||
// ErrGPUNotAvailable in that case.
|
||||
var (
|
||||
globalGPU *gpuHandle
|
||||
globalGPUMu sync.RWMutex
|
||||
)
|
||||
|
||||
// init runs the dlopen probe at package load time. Order is
|
||||
// cuda → hip → metal → vulkan → webgpu, matching the GPUBackend
|
||||
// numeric ordering. The first plugin whose dylib loads AND exposes
|
||||
// all four required symbols wins; later candidates are skipped.
|
||||
//
|
||||
// Search path order, per candidate:
|
||||
// 1. ${LUX_GPU_PLUGIN_DIR}/lib<name>.<ext> (operator-set override)
|
||||
// 2. lib<name>.<ext> (system dlopen lookup —
|
||||
// LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, rpath, default loader paths)
|
||||
//
|
||||
// A struct-size assertion runs ahead of the dlopen probes so a
|
||||
// build-side mismatch between Go's LuxAmmReservePair and the C ABI's
|
||||
// LuxAmmReservePair (the kind of bug pkg-config would catch at link
|
||||
// time but dlopen can't) fails fast instead of producing silent
|
||||
// byte-shifted output.
|
||||
func init() {
|
||||
// Layout assertion: LuxAmmReservePair MUST be 16 bytes packed.
|
||||
// Bigger means Go added padding and the ABI byte layout diverges.
|
||||
const wantAmmReserveBytes = 16
|
||||
if unsafe.Sizeof(LuxAmmReservePair{}) != wantAmmReserveBytes {
|
||||
panic(fmt.Sprintf(
|
||||
"dexvm: LuxAmmReservePair layout drift — got %d bytes, want %d. "+
|
||||
"Sync include/lux/gpu/dex.h::LuxAmmReservePair with backend.go.",
|
||||
unsafe.Sizeof(LuxAmmReservePair{}), wantAmmReserveBytes))
|
||||
}
|
||||
|
||||
for _, b := range probeOrder() {
|
||||
if h := tryLoadBackend(b); h != nil {
|
||||
globalGPUMu.Lock()
|
||||
globalGPU = h
|
||||
globalGPUMu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
// No plugin available — every method returns ErrGPUNotAvailable.
|
||||
// Intentionally NOT a panic: a node on a CPU-only host is fine,
|
||||
// it just falls back to the in-Go orderbook path.
|
||||
}
|
||||
|
||||
// probeOrder returns the canonical dlopen probe order. CUDA first so
|
||||
// NVIDIA hosts pick up the native ICD; HIP next for AMD ROCm; Metal
|
||||
// is the Apple Silicon native path; Vulkan covers the rest (cross-
|
||||
// vendor portable, MoltenVK on Apple, native ICDs on Linux/Windows);
|
||||
// WebGPU (wgpu / Dawn) is the universal last-resort.
|
||||
func probeOrder() []GPUBackend {
|
||||
return []GPUBackend{
|
||||
GPUBackendCUDA,
|
||||
GPUBackendHIP,
|
||||
GPUBackendMetal,
|
||||
GPUBackendVulkan,
|
||||
GPUBackendWebGPU,
|
||||
}
|
||||
}
|
||||
|
||||
// dylibExt returns the platform-specific shared-library extension. We
|
||||
// search both the platform-native form (.dylib on darwin, .so on
|
||||
// linux, .dll on windows-via-cgo) and the unix-style .so as a
|
||||
// fallback — the Vulkan loader, for example, sometimes lives under
|
||||
// libluxgpu_backend_vulkan.so even on darwin (when built by CMake's
|
||||
// MODULE_LIBRARY default).
|
||||
func dylibExt() []string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return []string{".dylib", ".so"}
|
||||
case "windows":
|
||||
return []string{".dll"}
|
||||
default:
|
||||
return []string{".so"}
|
||||
}
|
||||
}
|
||||
|
||||
// pluginCandidatePaths returns the dlopen candidates for one backend,
|
||||
// in lookup order: the operator override directory first (when set),
|
||||
// then the bare library name so the system loader's standard search
|
||||
// path takes over (LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, rpath, default
|
||||
// loader paths).
|
||||
func pluginCandidatePaths(b GPUBackend) []string {
|
||||
base := "libluxgpu_backend_" + b.String()
|
||||
exts := dylibExt()
|
||||
|
||||
var out []string
|
||||
if dir := os.Getenv("LUX_GPU_PLUGIN_DIR"); dir != "" {
|
||||
for _, ext := range exts {
|
||||
out = append(out, dir+"/"+base+ext)
|
||||
}
|
||||
}
|
||||
for _, ext := range exts {
|
||||
out = append(out, base+ext)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tryLoadBackend dlopen's the candidate paths for one backend, and
|
||||
// resolves the four required symbols. Returns nil if either the
|
||||
// dylib won't load OR any required symbol is missing. Symbol misses
|
||||
// are treated as fatal-for-this-backend (we don't accept a partial
|
||||
// plugin — that would mask a build skew and produce a "GPU loaded
|
||||
// but CLOB returns garbage" failure mode that's worse than CPU).
|
||||
func tryLoadBackend(b GPUBackend) *gpuHandle {
|
||||
candidates := pluginCandidatePaths(b)
|
||||
for _, path := range candidates {
|
||||
// Clear dlerror() before dlopen so a previous miss doesn't
|
||||
// confuse the post-failure diagnostic.
|
||||
C.lux_dlerror()
|
||||
cpath := C.CString(path)
|
||||
hLib := C.lux_dlopen(cpath)
|
||||
C.free(unsafe.Pointer(cpath))
|
||||
if hLib == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
prefix := "lux_" + b.String() + "_"
|
||||
var (
|
||||
fnAMM = dlsymOrNil(hLib, prefix+"amm_xyk_batch")
|
||||
fnCLOB = dlsymOrNil(hLib, prefix+"dex_clob_match")
|
||||
fnCreate = dlsymOrNil(hLib, prefix+"dex_clob_arena_create")
|
||||
fnDestr = dlsymOrNil(hLib, prefix+"dex_clob_arena_destroy")
|
||||
)
|
||||
if fnAMM == nil || fnCLOB == nil || fnCreate == nil || fnDestr == nil {
|
||||
// Partial export set — plausible cause is a stale plugin
|
||||
// build that pre-dates the DEX launcher landing. Close
|
||||
// the handle so we don't keep a dangling mapping, and
|
||||
// keep probing.
|
||||
C.lux_dlclose(hLib)
|
||||
continue
|
||||
}
|
||||
return &gpuHandle{
|
||||
backend: b,
|
||||
libPath: path,
|
||||
hLib: hLib,
|
||||
fnAMMSwap: fnAMM,
|
||||
fnCLOBMatch: fnCLOB,
|
||||
fnArenaCreate: fnCreate,
|
||||
fnArenaDestroy: fnDestr,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dlsymOrNil wraps dlsym() — returns nil on lookup failure so the
|
||||
// caller can fail uniformly without parsing dlerror() strings.
|
||||
func dlsymOrNil(h unsafe.Pointer, name string) unsafe.Pointer {
|
||||
cname := C.CString(name)
|
||||
defer C.free(unsafe.Pointer(cname))
|
||||
C.lux_dlerror()
|
||||
p := C.lux_dlsym(h, cname)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// AutoBackend returns the GPU backend bound at init(), or
|
||||
// GPUBackendNone if no plugin loaded. Callers branch on this to
|
||||
// decide between the GPU path and the CPU fallback.
|
||||
func AutoBackend() GPUBackend {
|
||||
globalGPUMu.RLock()
|
||||
defer globalGPUMu.RUnlock()
|
||||
if globalGPU == nil {
|
||||
return GPUBackendNone
|
||||
}
|
||||
return globalGPU.backend
|
||||
}
|
||||
|
||||
// GPUPluginPath returns the dylib path the active backend was loaded
|
||||
// from, useful for diagnostic logging at node startup. Empty string
|
||||
// when no plugin loaded.
|
||||
func GPUPluginPath() string {
|
||||
globalGPUMu.RLock()
|
||||
defer globalGPUMu.RUnlock()
|
||||
if globalGPU == nil {
|
||||
return ""
|
||||
}
|
||||
return globalGPU.libPath
|
||||
}
|
||||
|
||||
// activeGPU returns the loaded handle or ErrGPUNotAvailable. Every
|
||||
// public method funnels through this so the nil-check is in one place.
|
||||
func activeGPU() (*gpuHandle, error) {
|
||||
globalGPUMu.RLock()
|
||||
defer globalGPUMu.RUnlock()
|
||||
if globalGPU == nil {
|
||||
return nil, ErrGPUNotAvailable
|
||||
}
|
||||
return globalGPU, nil
|
||||
}
|
||||
|
||||
// AMMSwap runs the constant-product (xy=k) swap kernel over `n` pools
|
||||
// in one batched dispatch. `reserves[i]` is the (ReserveX, ReserveY)
|
||||
// pair for pool i and `amounts[i]` is the input amount. The output
|
||||
// `outs[i]` is the receive-side amount:
|
||||
//
|
||||
// outs[i] = (amounts[i] * reserves[i].ReserveY)
|
||||
// / (reserves[i].ReserveX + amounts[i])
|
||||
//
|
||||
// Byte-equal across all five GPU backends and to the CPU oracle at
|
||||
// ~/work/lux/dex/pkg/lx::ConstantProductOut. Slices must be the same
|
||||
// length; a length mismatch is a caller bug (returns an error rather
|
||||
// than panicking).
|
||||
//
|
||||
// Memory safety: every passed Go slice is pinned with runtime.Pinner
|
||||
// for the duration of the C call. The pinner is unpinned via defer
|
||||
// on every return path including the error path. runtime.KeepAlive
|
||||
// keeps the slices reachable until C returns even if the compiler
|
||||
// sees no further Go-side reference.
|
||||
func AMMSwap(reserves []LuxAmmReservePair, amounts []uint64) ([]uint64, error) {
|
||||
h, err := activeGPU()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(reserves) != len(amounts) {
|
||||
return nil, fmt.Errorf("dexvm.AMMSwap: reserves (n=%d) != amounts (n=%d)",
|
||||
len(reserves), len(amounts))
|
||||
}
|
||||
n := len(reserves)
|
||||
outs := make([]uint64, n)
|
||||
if n == 0 {
|
||||
return outs, nil
|
||||
}
|
||||
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
pinner.Pin(&reserves[0])
|
||||
pinner.Pin(&amounts[0])
|
||||
pinner.Pin(&outs[0])
|
||||
|
||||
rc := C.call_amm_xyk_batch(
|
||||
h.fnAMMSwap,
|
||||
unsafe.Pointer(&reserves[0]),
|
||||
unsafe.Pointer(&amounts[0]),
|
||||
unsafe.Pointer(&outs[0]),
|
||||
C.uint32_t(n),
|
||||
)
|
||||
runtime.KeepAlive(reserves)
|
||||
runtime.KeepAlive(amounts)
|
||||
runtime.KeepAlive(outs)
|
||||
if rc != 0 {
|
||||
return nil, fmt.Errorf("dexvm.AMMSwap: %s plugin returned rc=%d",
|
||||
h.backend, int(rc))
|
||||
}
|
||||
return outs, nil
|
||||
}
|
||||
|
||||
// CLOBArena is the opaque handle to a device-resident BookArena. The
|
||||
// pointer crosses the cgo boundary as void* — Go never dereferences
|
||||
// it, only passes it to ArenaDestroy or CLOBMatch.
|
||||
//
|
||||
// ArenaCreate hands ownership to the Go caller; ArenaDestroy releases
|
||||
// it. The arena lives across many CLOBMatch calls — that's the
|
||||
// entire reason it's device-resident: the BookArena holds the
|
||||
// resting bids/asks for a single book, and successive CLOBMatch
|
||||
// calls on the same book MUST share state.
|
||||
type CLOBArena struct {
|
||||
ptr unsafe.Pointer
|
||||
}
|
||||
|
||||
// ArenaCreate allocates a fresh device-resident BookArena (zero
|
||||
// initialized). The returned handle is owned by the caller; call
|
||||
// ArenaDestroy to free it. Concurrent ArenaCreate is safe.
|
||||
func ArenaCreate() (*CLOBArena, error) {
|
||||
h, err := activeGPU()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw unsafe.Pointer
|
||||
rc := C.call_dex_clob_arena_create(h.fnArenaCreate, &raw)
|
||||
if rc != 0 {
|
||||
return nil, fmt.Errorf("dexvm.ArenaCreate: %s plugin returned rc=%d",
|
||||
h.backend, int(rc))
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, fmt.Errorf("dexvm.ArenaCreate: %s plugin returned NULL arena",
|
||||
h.backend)
|
||||
}
|
||||
return &CLOBArena{ptr: raw}, nil
|
||||
}
|
||||
|
||||
// ArenaDestroy releases the device-resident BookArena. Calling it on
|
||||
// a nil arena is a no-op (matches the C-side symmetry). Calling it
|
||||
// twice on the same arena is UB at the plugin level — don't.
|
||||
func ArenaDestroy(a *CLOBArena) error {
|
||||
if a == nil || a.ptr == nil {
|
||||
return nil
|
||||
}
|
||||
h, err := activeGPU()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc := C.call_dex_clob_arena_destroy(h.fnArenaDestroy, a.ptr)
|
||||
a.ptr = nil
|
||||
if rc != 0 {
|
||||
return fmt.Errorf("dexvm.ArenaDestroy: %s plugin returned rc=%d",
|
||||
h.backend, int(rc))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CLOBMatch runs one matcher step against the arena. calldata is the
|
||||
// 117-byte EVM precompile 0x100 input — see backend.go for the byte
|
||||
// layout. Output is a fixed-size 68-byte buffer (filled + avg_price +
|
||||
// num_fills) plus a num_fills uint32 mirror for callers that want it
|
||||
// without re-parsing the 68 bytes.
|
||||
//
|
||||
// Single matcher pass per call; concurrency is across book_ids — each
|
||||
// book gets its own arena, and independent arenas can be matched in
|
||||
// parallel on different goroutines without serialization.
|
||||
func CLOBMatch(a *CLOBArena, calldata []byte) (out [LuxCLOBOutLen]byte, numFills uint32, err error) {
|
||||
h, gerr := activeGPU()
|
||||
if gerr != nil {
|
||||
return out, 0, gerr
|
||||
}
|
||||
if a == nil || a.ptr == nil {
|
||||
return out, 0, fmt.Errorf("dexvm.CLOBMatch: arena is nil")
|
||||
}
|
||||
if len(calldata) != LuxCLOBCalldataLen {
|
||||
return out, 0, fmt.Errorf("dexvm.CLOBMatch: calldata len=%d, want %d",
|
||||
len(calldata), LuxCLOBCalldataLen)
|
||||
}
|
||||
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
pinner.Pin(&calldata[0])
|
||||
pinner.Pin(&out[0])
|
||||
|
||||
var nf C.uint32_t
|
||||
rc := C.call_dex_clob_match(
|
||||
h.fnCLOBMatch,
|
||||
a.ptr,
|
||||
(*C.uint8_t)(unsafe.Pointer(&calldata[0])),
|
||||
(*C.uint8_t)(unsafe.Pointer(&out[0])),
|
||||
&nf,
|
||||
)
|
||||
runtime.KeepAlive(calldata)
|
||||
runtime.KeepAlive(out)
|
||||
if rc != 0 {
|
||||
return out, 0, fmt.Errorf("dexvm.CLOBMatch: %s plugin returned rc=%d",
|
||||
h.backend, int(rc))
|
||||
}
|
||||
return out, uint32(nf), nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build !cgo
|
||||
|
||||
// dexvm_gpu_nocgo.go — nocgo stub for the GPU plugin bridge.
|
||||
//
|
||||
// When CGO_ENABLED=0 the dlopen path in dexvm_gpu.go is unreachable,
|
||||
// so every public method returns ErrGPUNotAvailable and AutoBackend()
|
||||
// reports GPUBackendNone. The package surface is identical to the
|
||||
// cgo build — consumers compile unconditionally and branch on
|
||||
// AutoBackend() at runtime.
|
||||
|
||||
package dexvm
|
||||
|
||||
// AutoBackend reports GPUBackendNone under !cgo — dlopen is unreachable.
|
||||
func AutoBackend() GPUBackend { return GPUBackendNone }
|
||||
|
||||
// GPUPluginPath returns "" under !cgo — no plugin was loaded.
|
||||
func GPUPluginPath() string { return "" }
|
||||
|
||||
// CLOBArena is a zero-sized opaque type so the package surface
|
||||
// compiles uniformly under both build tags. The nocgo build never
|
||||
// returns a non-nil arena from ArenaCreate.
|
||||
type CLOBArena struct{}
|
||||
|
||||
// AMMSwap returns ErrGPUNotAvailable under !cgo.
|
||||
func AMMSwap(reserves []LuxAmmReservePair, amounts []uint64) ([]uint64, error) {
|
||||
_ = reserves
|
||||
_ = amounts
|
||||
return nil, ErrGPUNotAvailable
|
||||
}
|
||||
|
||||
// ArenaCreate returns ErrGPUNotAvailable under !cgo.
|
||||
func ArenaCreate() (*CLOBArena, error) {
|
||||
return nil, ErrGPUNotAvailable
|
||||
}
|
||||
|
||||
// ArenaDestroy returns ErrGPUNotAvailable under !cgo. A nil arena is
|
||||
// still a no-op for symmetry with the cgo build.
|
||||
func ArenaDestroy(a *CLOBArena) error {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return ErrGPUNotAvailable
|
||||
}
|
||||
|
||||
// CLOBMatch returns ErrGPUNotAvailable under !cgo.
|
||||
func CLOBMatch(a *CLOBArena, calldata []byte) (out [LuxCLOBOutLen]byte, numFills uint32, err error) {
|
||||
_ = a
|
||||
_ = calldata
|
||||
return out, 0, ErrGPUNotAvailable
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build cgo
|
||||
|
||||
// dexvm_gpu_test.go — round-trip test for the GPU plugin bridge.
|
||||
//
|
||||
// Tests the dlopen→dlsym→call→result path against a real plugin
|
||||
// dylib. The plugin path is supplied via LUX_GPU_PLUGIN_DIR; when
|
||||
// unset (or no plugin matches), the test skips cleanly so the
|
||||
// package builds + tests on CPU-only CI hosts.
|
||||
//
|
||||
// Fixture: a single-pool AMM swap with hand-chosen reserves whose
|
||||
// constant-product result is small enough to write by hand and
|
||||
// exact under integer arithmetic. The same fixture is what the
|
||||
// canonical CPU oracle (~/work/lux/dex/pkg/lx::ConstantProductOut)
|
||||
// returns, so a mismatch means the plugin produced wrong bytes.
|
||||
|
||||
package dexvm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// constantProductOutCPU is the in-test CPU oracle, kept self-contained
|
||||
// so we don't introduce a test-time dependency on ~/work/lux/dex.
|
||||
// Computes (a * b) / d using two uint64 multiplies and a uint128
|
||||
// shift-divide. For our fixture a*b fits in 64 bits — keep the path
|
||||
// uint64-only and assert numerically.
|
||||
func constantProductOutCPU(rx, ry, amount uint64) uint64 {
|
||||
denom := rx + amount
|
||||
if denom == 0 {
|
||||
return 0
|
||||
}
|
||||
// Numerator a*b: 1_000 * 2_000_000 = 2_000_000_000 — fits in uint64.
|
||||
// Fixture is sized so the multiply stays in 64 bits; the GPU does
|
||||
// it in 128 bits internally regardless.
|
||||
return (amount * ry) / denom
|
||||
}
|
||||
|
||||
// TestGPUAMMSwapRoundTrip dlopen's a backend plugin from
|
||||
// LUX_GPU_PLUGIN_DIR, runs a single-pool xy=k swap on it, and
|
||||
// compares the kernel output to the CPU oracle.
|
||||
//
|
||||
// To run: build the plugin (e.g. via cmake at
|
||||
// ~/work/lux-private/gpu-kernels/build/) and set
|
||||
//
|
||||
// export LUX_GPU_PLUGIN_DIR=$HOME/work/lux-private/gpu-kernels/build-metal/backends/metal
|
||||
// go test -tags cgo ./dexvm/ -run TestGPUAMMSwapRoundTrip -v
|
||||
func TestGPUAMMSwapRoundTrip(t *testing.T) {
|
||||
if os.Getenv("LUX_GPU_PLUGIN_DIR") == "" {
|
||||
// Best-effort: try the common build-tree paths so the test
|
||||
// runs without explicit env setup when run from a dev box
|
||||
// with a fresh checkout.
|
||||
findAndSetPluginDir(t)
|
||||
}
|
||||
|
||||
backend := AutoBackend()
|
||||
if backend == GPUBackendNone {
|
||||
t.Skipf("no GPU plugin loaded — set LUX_GPU_PLUGIN_DIR to a directory "+
|
||||
"containing libluxgpu_backend_*.{dylib,so} (probe order: cuda → hip → "+
|
||||
"metal → vulkan → webgpu). dlopen tried: %v", probeOrder())
|
||||
}
|
||||
t.Logf("loaded GPU plugin: backend=%s path=%s", backend, GPUPluginPath())
|
||||
|
||||
// Fixture: one pool, reserve_x=1_000_000, reserve_y=2_000_000,
|
||||
// amount_in=1_000. Expected receive_y = 1_998 (verified by hand
|
||||
// AND the CPU oracle below). The numbers are small on purpose:
|
||||
// (a) the multiply 1_000 * 2_000_000 fits in 64 bits so the
|
||||
// test is independent of the kernel's 128-bit multiply; (b) the
|
||||
// quotient 2_000_000_000 / 1_001_000 = 1_998 + r/1_001_000 with
|
||||
// r = 2_000_000_000 - 1_998 * 1_001_000 = 2_002_000 — non-zero
|
||||
// so we exercise the floor-division path.
|
||||
reserves := []LuxAmmReservePair{
|
||||
{ReserveX: 1_000_000, ReserveY: 2_000_000},
|
||||
}
|
||||
amounts := []uint64{1_000}
|
||||
wantOut := constantProductOutCPU(1_000_000, 2_000_000, 1_000)
|
||||
if wantOut != 1_998 {
|
||||
t.Fatalf("CPU oracle drift: got %d, want 1_998", wantOut)
|
||||
}
|
||||
|
||||
outs, err := AMMSwap(reserves, amounts)
|
||||
if err != nil {
|
||||
t.Fatalf("AMMSwap: %v", err)
|
||||
}
|
||||
if got, want := len(outs), len(amounts); got != want {
|
||||
t.Fatalf("AMMSwap returned wrong length: got %d, want %d", got, want)
|
||||
}
|
||||
if outs[0] != wantOut {
|
||||
t.Errorf("AMMSwap[0]: got %d, want %d (kernel returned wrong bytes — "+
|
||||
"plugin uint128 multiply may be byte-swapped or the input layout drifted)",
|
||||
outs[0], wantOut)
|
||||
}
|
||||
|
||||
// Sanity bounds: the receive amount MUST be > 0 (the pool has
|
||||
// reserves and the swap input is non-zero) AND MUST be strictly
|
||||
// less than reserve_y (xy=k is a strict bijection on positive
|
||||
// inputs — full drain is impossible).
|
||||
if outs[0] == 0 {
|
||||
t.Error("AMMSwap[0] = 0; non-degenerate input must produce a positive output")
|
||||
}
|
||||
if outs[0] >= reserves[0].ReserveY {
|
||||
t.Errorf("AMMSwap[0] = %d >= reserve_y = %d; xy=k invariant violated",
|
||||
outs[0], reserves[0].ReserveY)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGPUAMMSwapEmpty verifies the n=0 fast path — no plugin call,
|
||||
// just a zero-length slice. Runs regardless of whether a plugin is
|
||||
// loaded so the bookkeeping path stays covered on CPU-only hosts.
|
||||
func TestGPUAMMSwapEmpty(t *testing.T) {
|
||||
outs, err := AMMSwap(nil, nil)
|
||||
if AutoBackend() == GPUBackendNone {
|
||||
if err != ErrGPUNotAvailable {
|
||||
t.Fatalf("AMMSwap on n=0 with no plugin: got err=%v, want ErrGPUNotAvailable", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("AMMSwap(nil, nil): %v", err)
|
||||
}
|
||||
if len(outs) != 0 {
|
||||
t.Fatalf("AMMSwap(nil, nil) returned len=%d, want 0", len(outs))
|
||||
}
|
||||
}
|
||||
|
||||
// findAndSetPluginDir best-effort probe of the well-known build-tree
|
||||
// locations under ~/work/lux-private/gpu-kernels. Sets
|
||||
// LUX_GPU_PLUGIN_DIR if a candidate dylib exists. The test still
|
||||
// skips cleanly if none is found.
|
||||
func findAndSetPluginDir(t *testing.T) {
|
||||
t.Helper()
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
root := filepath.Join(home, "work", "lux-private", "gpu-kernels")
|
||||
candidates := []struct {
|
||||
subdir string
|
||||
name string
|
||||
}{
|
||||
{"build-metal/backends/metal", "libluxgpu_backend_metal.dylib"},
|
||||
{"build/backends/metal", "libluxgpu_backend_metal.dylib"},
|
||||
{"build/backends/vulkan", "libluxgpu_backend_vulkan.dylib"},
|
||||
{"build/backends/vulkan", "libluxgpu_backend_vulkan.so"},
|
||||
{"build/backends/webgpu", "libluxgpu_backend_webgpu.dylib"},
|
||||
{"build/backends/webgpu", "libluxgpu_backend_webgpu.so"},
|
||||
{"build/backends/cuda", "libluxgpu_backend_cuda.so"},
|
||||
{"build/backends/hip", "libluxgpu_backend_hip.so"},
|
||||
}
|
||||
for _, c := range candidates {
|
||||
path := filepath.Join(root, c.subdir, c.name)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
// Found one — set the dir and trigger a re-probe by
|
||||
// telling the operator to re-run the binary; init() has
|
||||
// already run, so we can only inform the user via t.Logf.
|
||||
t.Logf("found candidate plugin: %s (LUX_GPU_PLUGIN_DIR must be set "+
|
||||
"BEFORE the test binary starts so init() can dlopen it)", path)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user