Files
chains/mpcvm/gpu_plugin_test.go
T
zeekay 6981a3cd6c mpcvm: M-Chain to 98.6%
The lane that did this was killed before it could commit; the tree it left is
green and is preserved here rather than lost. Root package 51.5% at the start
of the day, 93.3% at its last checkpoint, 98.6% now, with cert, fhe, types and
cmd/plugin all passing.

Includes the fault-injection suite it was building when it stopped: a node that
cannot read its state does not start, and one that cannot enumerate its
registry does not either.

Not reviewed by me beyond build, vet, gofmt and the full suite under -count=1.
The lane's own report is owed and did not arrive, so its mutation count and its
verdict on the mpcvm/fhe duplication are unrecorded.
2026-08-28 13:25:30 -07:00

395 lines
16 KiB
Go

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo
package mpcvm
// gpu_plugin_test.go — the dlopen bridge, driven against a real plugin.
//
// The GPU bridge is a runtime overlay: it dlopens a backend plugin, dlsyms four
// launchers, and hands them host pointers to the wire structs. What can be
// wrong here is not the arithmetic inside a kernel — that is the kernel's
// business — but the BRIDGE: whether a missing symbol is noticed, whether the
// counts the launcher receives are the ones the caller passed, and whether a
// launcher's non-zero return reaches the caller as an error instead of as a
// zero count the caller reads as success.
//
// So the plugin under test is a real shared object, compiled here, whose
// launchers ECHO what they were handed back through the output buffers the ABI
// already gives them. That exercises the same dlopen, the same dlsym and the
// same call shims a CUDA plugin would, with the kernel replaced by a mirror. No
// GPU is required and none is simulated: the arithmetic is not what this file
// is about, and pretending otherwise would be a test that passes when the
// bridge and the mirror are wrong in the same way.
//
// The mirror's contract, which the tests below read back:
//
// rc — every launcher returns desc->Mode (compute_leaves, which gets
// no descriptor, returns the low half of ceremonies[0].CeremonyID)
// *capp — ceremony_count *napp — next_contribution_id (truncated)
// *ra — ceremony_count *fin — key_share_count
// *fai — next_share_id (truncated)
// result — Status=7, ActiveCeremonyCount=ceremony_count,
// KeyShareCount=key_share_count; state.CurrentEpoch += 1
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
const stubSource = `
#include <stdint.h>
#include <string.h>
// The descriptor's Mode field, at offset 32, carries the return code the
// caller wants back — so one plugin covers the success and the failure path
// without a second build.
static int rc_of(const void* desc) { return (int)*(const uint32_t*)((const char*)desc + 32); }
int lux_cuda_mpcvm_ceremony_apply(
const void* desc, const void* cops, const void* nops,
void* cer, void* con, void* capp, void* napp,
uint32_t cc, uint32_t nc, uint64_t next_cont, void* stream)
{
(void)cops; (void)nops; (void)cer; (void)con; (void)stream;
*(uint32_t*)capp = cc;
*(uint32_t*)napp = (uint32_t)next_cont;
return rc_of(desc);
}
int lux_cuda_mpcvm_ceremony_sweep(
const void* desc, void* cer, void* ks, void* con,
void* ra, void* fin, void* fai,
uint32_t cc, uint32_t kc, uint32_t nc, uint64_t next_share, void* stream)
{
(void)cer; (void)ks; (void)con; (void)nc; (void)stream;
*(uint32_t*)ra = cc;
*(uint32_t*)fin = kc;
*(uint32_t*)fai = (uint32_t)next_share;
return rc_of(desc);
}
int lux_cuda_mpcvm_compute_leaves(
const void* cer, const void* ks, const void* con,
void* cl, void* sl, void* nl,
void* ac, void* fc, void* fac, void* sc,
void* cm, void* sm, void* nm,
uint32_t cc, uint32_t kc, uint32_t nc, void* stream)
{
(void)ks; (void)con; (void)stream;
memset(cl, 0xAA, (size_t)cc * 32);
memset(sl, 0xBB, (size_t)kc * 32);
memset(nl, 0xCC, (size_t)nc * 32);
memset(cm, 1, cc); memset(sm, 1, kc); memset(nm, 1, nc);
*(uint32_t*)ac = cc; *(uint32_t*)fc = kc; *(uint32_t*)fac = nc; *(uint32_t*)sc = kc;
// No descriptor reaches this launcher; the first ceremony slot carries the
// return code instead.
return (int)*(const uint32_t*)cer;
}
int lux_cuda_mpcvm_compose_root(
const void* desc,
const void* cl, const void* sl, const void* nl,
const void* cm, const void* sm, const void* nm,
const void* ac, const void* fc, const void* fac, const void* sc,
void* state, void* result,
uint32_t cc, uint32_t kc, uint32_t nc, void* stream)
{
(void)cl; (void)sl; (void)nl; (void)cm; (void)sm; (void)nm;
(void)fc; (void)fac; (void)cc; (void)kc; (void)nc; (void)stream;
uint32_t* out = (uint32_t*)result;
out[0] = 7; // Status
out[5] = *(const uint32_t*)ac; // ActiveCeremonyCount
out[6] = *(const uint32_t*)sc; // KeyShareCount
((uint64_t*)state)[0] += 1; // CurrentEpoch advances in place
return rc_of(desc);
}
`
var (
stubOnce sync.Once
stubPath string
stubErr error
)
// stubPlugin compiles the mirror plugin once. A C compiler is present by
// construction: this file only builds under cgo, and cgo needed one.
func stubPlugin(t *testing.T) string {
t.Helper()
stubOnce.Do(func() {
dir, err := os.MkdirTemp("", "mpcvm-gpu-stub")
if err != nil {
stubErr = err
return
}
src := filepath.Join(dir, "stub.c")
if err := os.WriteFile(src, []byte(stubSource), 0o600); err != nil {
stubErr = err
return
}
out := filepath.Join(dir, "libluxgpu_backend_cuda.so")
if combined, err := exec.Command(compiler(), "-shared", "-fPIC", "-o", out, src).CombinedOutput(); err != nil {
stubErr = fmt.Errorf("%s: %w: %s", compiler(), err, combined)
return
}
stubPath = out
})
require.NoError(t, stubErr)
return stubPath
}
func compiler() string {
if cc := os.Getenv("CC"); cc != "" {
return cc
}
return "cc"
}
// loadStub resolves the mirror plugin through the production loader.
func loadStub(t *testing.T) *GPUBackend {
t.Helper()
b := tryLoadPlugin(GPUBackendCUDA, stubPlugin(t))
require.NotNil(t, b, "the loader did not resolve a plugin exporting all four launchers")
require.Equal(t, GPUBackendCUDA, b.Kind)
require.Equal(t, stubPlugin(t), b.Path)
require.True(t, b.IsAvailable())
return b
}
// round is a descriptor whose Mode carries the return code the mirror echoes.
func round(rc uint32) *GPUMPCVMRoundDescriptor {
return &GPUMPCVMRoundDescriptor{ChainID: 1, Round: 2, Mode: rc}
}
// -----------------------------------------------------------------------------
// Resolution
// -----------------------------------------------------------------------------
// A plugin is used only when the launchers it must export actually resolve. A
// library that dlopens but exports nothing is not a backend, and adopting one
// would call a null pointer on the first round.
func TestALibraryWithoutTheLaunchersIsNotABackend(t *testing.T) {
require.Nil(t, tryLoadPlugin(GPUBackendCUDA, "libc.so.6", "libSystem.B.dylib"),
"a library missing ceremony_apply/ceremony_sweep must not be adopted as a backend")
}
func TestAPluginThatIsNotThereIsNotABackend(t *testing.T) {
require.Nil(t, tryLoadPlugin(GPUBackendCUDA, "/nonexistent/a.so", "/nonexistent/b.so", "/nonexistent/c.so"))
require.Nil(t, tryLoadPlugin(GPUBackendCUDA))
require.Nil(t, tryLoadPlugin(GPUBackendCUDA, "", "", ""))
require.Nil(t, tryLoadPlugin(GPUBackendNone, "anything"),
"a backend with no launcher prefix names no symbols to resolve")
}
// The probe walks the substrate priority order and adopts the first backend
// that resolves. A host with no plugin adopts none and runs on the CPU
// reference, rather than adopting a broken one.
func TestTheProbeAdoptsTheFirstBackendThatResolves(t *testing.T) {
t.Setenv("LUX_GPU_PLUGIN_DIR", filepath.Dir(stubPlugin(t)))
t.Setenv("LUXCPP_PREFIX", "/nonexistent")
got := probeGPUBackend()
require.NotNil(t, got)
require.Equal(t, GPUBackendCUDA, got.Kind, "cuda is first in the substrate priority order")
t.Setenv("LUX_GPU_PLUGIN_DIR", "/nonexistent")
require.Nil(t, probeGPUBackend(), "a host with no plugin runs on the CPU reference")
}
// Every backend has candidate filenames, ordered: install path, then build
// tree, then the bare leaf for the dynamic linker's own search.
func TestEveryBackendHasCandidateFilenames(t *testing.T) {
t.Setenv("LUXCPP_PREFIX", "/opt/luxcpp")
t.Setenv("LUX_GPU_PLUGIN_DIR", "/build/plugins")
for _, kind := range []GPUBackendKind{
GPUBackendCUDA, GPUBackendHIP, GPUBackendMetal, GPUBackendVulkan, GPUBackendWebGPU,
} {
got := candidatesFor(kind)
require.NotEmptyf(t, got, "%s has no candidate filenames", kind)
require.Containsf(t, got[0], "/opt/luxcpp/lib/", "%s must try the install path first", kind)
require.Truef(t, hasPrefixDir(got, "/build/plugins"), "%s must try the build tree", kind)
require.Containsf(t, got, filepath.Base(got[0]), "%s must fall back to the bare leaf name", kind)
require.NotEmptyf(t, kind.String(), "%s has no launcher prefix", kind)
}
require.Empty(t, candidatesFor(GPUBackendNone))
require.Empty(t, backendDylibLeaves(GPUBackendNone))
// With no explicit prefix, the home-relative install tree stands in.
t.Setenv("LUXCPP_PREFIX", "")
t.Setenv("LUX_GPU_PLUGIN_DIR", "")
t.Setenv("HOME", "/home/somebody")
require.Contains(t, candidatesFor(GPUBackendCUDA)[0], "/home/somebody/work/luxcpp/install/lib/")
// With neither, only the bare leaves remain.
t.Setenv("HOME", "")
require.Equal(t, backendDylibLeaves(GPUBackendCUDA), candidatesFor(GPUBackendCUDA))
}
// -----------------------------------------------------------------------------
// The four launchers
// -----------------------------------------------------------------------------
// Each method hands the launcher the counts and buffers the caller passed, and
// returns what the launcher wrote back into them.
func TestTheBridgePassesTheCallersCountsAndReadsBackTheResult(t *testing.T) {
b := loadStub(t)
desc := round(0)
ceremonies := make([]GPUCeremony, 4)
contributions := make([]GPUContribution, 3)
shares := make([]GPUKeyShare, 2)
applied, err := b.CeremonyApply(desc, []GPUCeremonyOp{{CeremonyID: 9}}, ceremonies)
require.NoError(t, err)
require.Equal(t, uint32(len(ceremonies)), applied, "the ceremony table's length reaches the launcher")
applied, err = b.ContributionApply(desc, []GPUContributionOp{{CeremonyID: 9}}, ceremonies, contributions, 41)
require.NoError(t, err)
require.Equal(t, uint32(41), applied, "the contribution-id counter reaches the launcher")
advance, finalized, failed, err := b.KeyShareApply(desc, ceremonies, shares, contributions, 77)
require.NoError(t, err)
require.Equal(t, uint32(len(ceremonies)), advance)
require.Equal(t, uint32(len(shares)), finalized)
require.Equal(t, uint32(77), failed, "the share-id counter reaches the launcher")
state := &GPUMPCVMState{CurrentEpoch: 5}
result, err := b.MPCTransition(desc, ceremonies, shares, contributions, state)
require.NoError(t, err)
require.Equal(t, uint32(7), result.Status)
require.Equal(t, uint32(len(ceremonies)), result.ActiveCeremonyCount,
"the leaf pass's counts reach the fold pass")
require.Equal(t, uint32(len(shares)), result.KeyShareCount)
require.Equal(t, uint64(6), state.CurrentEpoch, "the substrate state advances in place")
}
// An empty op stream is a legal call: the ceremony-admin and contribution
// dispatches share one launcher and differ only in which stream is populated.
func TestAnEmptyOpStreamStillDispatches(t *testing.T) {
b := loadStub(t)
desc := round(0)
ceremonies := make([]GPUCeremony, 2)
contributions := make([]GPUContribution, 2)
_, err := b.CeremonyApply(desc, nil, ceremonies)
require.NoError(t, err)
_, err = b.ContributionApply(desc, nil, ceremonies, contributions, 0)
require.NoError(t, err)
}
// A launcher that fails is an error, not a zero count. A caller that read the
// count without the error would apply nothing and believe it applied
// everything.
func TestALauncherFailureIsAnErrorNotAZeroCount(t *testing.T) {
b := loadStub(t)
desc := round(4) // 4 = dispatch, in the launcher's own vocabulary
ceremonies := make([]GPUCeremony, 1)
contributions := make([]GPUContribution, 1)
shares := make([]GPUKeyShare, 1)
_, err := b.CeremonyApply(desc, []GPUCeremonyOp{{}}, ceremonies)
require.ErrorContains(t, err, "rc=4")
_, err = b.ContributionApply(desc, []GPUContributionOp{{}}, ceremonies, contributions, 0)
require.ErrorContains(t, err, "rc=4")
_, _, _, err = b.KeyShareApply(desc, ceremonies, shares, contributions, 0)
require.ErrorContains(t, err, "rc=4")
// Both halves of the transition report separately, so an operator knows
// which pass failed.
_, err = b.MPCTransition(desc, ceremonies, shares, contributions, &GPUMPCVMState{})
require.ErrorContains(t, err, "compose_root: launcher rc=4")
ceremonies[0].CeremonyID = 5 // the leaf pass's return code
_, err = b.MPCTransition(round(0), ceremonies, shares, contributions, &GPUMPCVMState{})
require.ErrorContains(t, err, "compute_leaves: launcher rc=5")
}
// An empty arena is refused before any pointer is taken. Taking &slice[0] of an
// empty slice panics, so this is what stands between an empty round and a
// crash.
func TestAnEmptyArenaIsRefusedBeforeAPointerIsTaken(t *testing.T) {
b := loadStub(t)
desc := round(0)
ceremonies := make([]GPUCeremony, 1)
contributions := make([]GPUContribution, 1)
shares := make([]GPUKeyShare, 1)
_, err := b.CeremonyApply(nil, nil, ceremonies)
require.ErrorContains(t, err, "nil desc")
_, err = b.CeremonyApply(desc, nil, nil)
require.ErrorContains(t, err, "empty ceremonies")
_, err = b.ContributionApply(nil, nil, ceremonies, contributions, 0)
require.ErrorContains(t, err, "empty arena")
_, err = b.ContributionApply(desc, nil, nil, contributions, 0)
require.ErrorContains(t, err, "empty arena")
_, err = b.ContributionApply(desc, nil, ceremonies, nil, 0)
require.ErrorContains(t, err, "empty arena")
_, _, _, err = b.KeyShareApply(nil, ceremonies, shares, contributions, 0)
require.ErrorContains(t, err, "empty arena")
_, _, _, err = b.KeyShareApply(desc, nil, shares, contributions, 0)
require.ErrorContains(t, err, "empty arena")
_, _, _, err = b.KeyShareApply(desc, ceremonies, nil, contributions, 0)
require.ErrorContains(t, err, "empty arena")
_, _, _, err = b.KeyShareApply(desc, ceremonies, shares, nil, 0)
require.ErrorContains(t, err, "empty arena")
_, err = b.MPCTransition(nil, ceremonies, shares, contributions, &GPUMPCVMState{})
require.ErrorContains(t, err, "empty arena")
_, err = b.MPCTransition(desc, ceremonies, shares, contributions, nil)
require.ErrorContains(t, err, "empty arena")
_, err = b.MPCTransition(desc, nil, shares, contributions, &GPUMPCVMState{})
require.ErrorContains(t, err, "empty arena")
_, err = b.MPCTransition(desc, ceremonies, nil, contributions, &GPUMPCVMState{})
require.ErrorContains(t, err, "empty arena")
_, err = b.MPCTransition(desc, ceremonies, shares, nil, &GPUMPCVMState{})
require.ErrorContains(t, err, "empty arena")
}
// A plugin exporting the two required launchers but not the two the whole
// transition needs is adopted for what it can do and refuses the rest by name,
// so an early port is usable rather than silently wrong.
func TestAPartialPluginIsUsedForWhatItExports(t *testing.T) {
b := loadStub(t)
partial := *b
partial.fnComputeLeaves = nil
require.True(t, partial.IsAvailable())
_, err := partial.MPCTransition(round(0),
make([]GPUCeremony, 1), make([]GPUKeyShare, 1), make([]GPUContribution, 1), &GPUMPCVMState{})
require.ErrorIs(t, err, ErrGPUNotAvailable)
require.ErrorContains(t, err, "compute_leaves or compose_root missing")
partial = *b
partial.fnComposeRoot = nil
_, err = partial.MPCTransition(round(0),
make([]GPUCeremony, 1), make([]GPUKeyShare, 1), make([]GPUContribution, 1), &GPUMPCVMState{})
require.ErrorIs(t, err, ErrGPUNotAvailable)
}
// hasPrefixDir reports whether any candidate lives under dir.
func hasPrefixDir(candidates []string, dir string) bool {
for _, c := range candidates {
if strings.HasPrefix(c, dir+string(filepath.Separator)) {
return true
}
}
return false
}
// A caller holding no backend at all asks the same question and gets the same
// answer, rather than dereferencing nothing.
func TestNoBackendIsNotAnAvailableOne(t *testing.T) {
require.False(t, (*GPUBackend)(nil).IsAvailable())
require.False(t, (&GPUBackend{}).IsAvailable(), "a zero backend has dlopened nothing")
}