DEX Go: BatchVerifyOrders Metal + AMM xyk Metal + Metal anchor

- pkg/lx/signed_order{,_cpu,_gpu,_nogpu}.go: SignedOrder with SigningHash
  and BatchVerifyOrders cgo bridge
- pkg/lx/signed_order_metal_anchor_darwin.{c,go}: keep linker from
  dropping libsecp256k1_metal.a from the static archive
- pkg/lx/amm{,_gpu,_nogpu}.go: ReservePair, ConstantProductOut,
  BatchEvalConstantProduct via Metal kernel
- pkg/lx/{signed_order,amm_gpu}_test.go: tests
- pkg/lx/orderbook.go, test/benchmark/orderbook_bench_test.go: misc

End-to-end through cgo (Apple M1 Max):
  BatchVerifyOrders CPU: 27.91 verifies/sec
  BatchVerifyOrders Metal: 87,229 verifies/sec
This commit is contained in:
2026-05-05 10:17:20 -07:00
parent b48ceaf847
commit f64cb4ea50
13 changed files with 870 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package lx
// ReservePair is the (reserve_x, reserve_y) tuple for a single pool. The
// memory layout MUST match the C `LuxAmmReservePair` in
// luxcpp/dex/gpu/metal/amm_xyk_driver.h and the Metal `ReservePair` in
// amm_xyk.metal — the same buffer is reinterpreted on all three sides.
type ReservePair struct {
ReserveX uint64
ReserveY uint64
}
// ConstantProductOut is the canonical CPU oracle for the xy=k AMM curve:
//
// out = (amount * reserve_y) / (reserve_x + amount)
//
// Computed in 128-bit so amount*reserve_y can't silently overflow. Returns
// 0 for the degenerate (rx + amount == 0) case so it lines up with the
// kernel's behaviour.
//
// This is the byte-equality reference for the GPU path.
func ConstantProductOut(rx, ry, amount uint64) uint64 {
denom := rx + amount
if denom == 0 {
return 0
}
return mulDiv64(amount, ry, denom)
}
// mulDiv64 = (a * b) / d using 128-bit intermediates so a*b can exceed 2^64
// without truncating. The result is undefined (and the caller's bug) if the
// quotient itself doesn't fit in 64 bits.
func mulDiv64(a, b, d uint64) uint64 {
// Split into 32-bit halves; each partial product fits in 64 bits.
aLo, aHi := a&0xFFFFFFFF, a>>32
bLo, bHi := b&0xFFFFFFFF, b>>32
ll := aLo * bLo
lh := aLo * bHi
hl := aHi * bLo
hh := aHi * bHi
mid := (ll >> 32) + (lh & 0xFFFFFFFF) + (hl & 0xFFFFFFFF)
lo := (ll & 0xFFFFFFFF) | (mid << 32)
hi := hh + (lh >> 32) + (hl >> 32) + (mid >> 32)
return divU128byU64(hi, lo, d)
}
// divU128byU64 — straight shift-subtract long division. Same algorithm as the
// Metal kernel so a per-bit divergence between paths is impossible.
func divU128byU64(hi, lo, d uint64) uint64 {
if hi == 0 {
return lo / d
}
var quotient, remainder uint64
for i := 127; i >= 0; i-- {
remainder <<= 1
var bit uint64
if i >= 64 {
bit = (hi >> uint(i-64)) & 1
} else {
bit = (lo >> uint(i)) & 1
}
remainder |= bit
if remainder >= d {
remainder -= d
if i < 64 {
quotient |= uint64(1) << uint(i)
}
}
}
return quotient
}
// BatchEvalConstantProductCPU is the per-pool CPU oracle, exposed for tests
// and as the fallback when the GPU build path isn't available.
func BatchEvalConstantProductCPU(reserves []ReservePair, amounts []uint64) ([]uint64, error) {
if len(reserves) != len(amounts) {
return nil, ErrInvalidOrder
}
out := make([]uint64, len(reserves))
for i := range reserves {
out[i] = ConstantProductOut(reserves[i].ReserveX, reserves[i].ReserveY, amounts[i])
}
return out, nil
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo && darwin
package lx
// GPU-accelerated batch xy=k AMM evaluation via Metal.
//
// The Metal kernel and Objective-C++ driver live in luxcpp/dex/gpu/metal:
// kernel: amm_xyk.metal -> built by cmake into amm_xyk.metallib
// driver: amm_xyk_driver.{h,mm} -> built into libamm_xyk_metal.a
//
// One-time build:
// cmake -S ~/work/luxcpp/dex -B ~/work/luxcpp/dex/build && \
// cmake --build ~/work/luxcpp/dex/build --target amm_xyk_metal
//
// The metallib's runtime path is read from $LUX_DEX_AMM_METALLIB; if unset
// we fall back to the canonical build-tree location.
/*
#cgo CFLAGS: -I${SRCDIR}/../../../../luxcpp/dex/gpu/metal
#cgo LDFLAGS: -L${SRCDIR}/../../../../luxcpp/dex/build
#cgo LDFLAGS: -lamm_xyk_metal -lstdc++
#cgo LDFLAGS: -framework Metal -framework Foundation
#include <stdint.h>
#include <stdlib.h>
#include "amm_xyk_driver.h"
*/
import "C"
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"unsafe"
)
// resolveMetallibPath returns the absolute path to the precompiled
// amm_xyk.metallib. We honour LUX_DEX_AMM_METALLIB so packagers can
// relocate the file at install time, but otherwise we fall back to the
// in-tree cmake build path so dev builds Just Work.
var (
metallibOnce sync.Once
metallibPath string
)
func resolveMetallibPath() string {
metallibOnce.Do(func() {
if p := os.Getenv("LUX_DEX_AMM_METALLIB"); p != "" {
metallibPath = p
return
}
// Default: ~/work/luxcpp/dex/build/amm_xyk.metallib. We can't
// rely on ${SRCDIR} at runtime (cgo only expands it at compile
// time), so we hardcode the canonical sibling layout that the
// repo enforces: lx and luxcpp are sibling directories under
// $LUX_WORKSPACE_ROOT (default ~/work).
root := os.Getenv("LUX_WORKSPACE_ROOT")
if root == "" {
home, err := os.UserHomeDir()
if err == nil {
root = filepath.Join(home, "work")
}
}
metallibPath = filepath.Join(root, "luxcpp", "dex", "build", "amm_xyk.metallib")
})
return metallibPath
}
// BatchEvalConstantProduct dispatches an N-tuple xy=k batch onto the Metal
// GPU and returns the per-tuple `out_amount`. Output is byte-identical to
// the CPU oracle BatchEvalConstantProductCPU — that contract is enforced by
// amm_gpu_test.go on every run.
//
// On any non-Apple build the cgo file is excluded by the `darwin` build tag
// and the nocgo fallback below answers via the CPU oracle.
func BatchEvalConstantProduct(reserves []ReservePair, amounts []uint64) ([]uint64, error) {
if len(reserves) != len(amounts) {
return nil, fmt.Errorf("lx: reserves/amounts length mismatch: %d vs %d",
len(reserves), len(amounts))
}
n := len(reserves)
if n == 0 {
return nil, nil
}
libPath := resolveMetallibPath()
if _, err := os.Stat(libPath); err != nil {
return nil, fmt.Errorf("lx: amm metallib not found at %q: %w "+
"(set LUX_DEX_AMM_METALLIB or run cmake --build "+
"~/work/luxcpp/dex/build --target amm_xyk_metal)", libPath, err)
}
out := make([]uint64, n)
// Pin the three host buffers and the metallib path C-string for the
// duration of the cgo dispatch. The driver does its own internal
// memcpy off these buffers into MTLBuffers, so the pin is only needed
// for the in-flight call.
var pinner runtime.Pinner
defer pinner.Unpin()
pinner.Pin(&reserves[0])
pinner.Pin(&amounts[0])
pinner.Pin(&out[0])
cPath := C.CString(libPath)
defer C.free(unsafe.Pointer(cPath))
rc := C.amm_xyk_batch_metal(
(*C.LuxAmmReservePair)(unsafe.Pointer(&reserves[0])),
(*C.uint64_t)(unsafe.Pointer(&amounts[0])),
(*C.uint64_t)(unsafe.Pointer(&out[0])),
C.uint32_t(n),
cPath,
)
runtime.KeepAlive(reserves)
runtime.KeepAlive(amounts)
runtime.KeepAlive(out)
if rc != 0 {
return nil, fmt.Errorf("lx: amm_xyk_batch_metal returned %d", int(rc))
}
return out, nil
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package lx
import (
"math/rand"
"testing"
)
// TestBatchEvalConstantProduct_GPUMatchesCPU dispatches 1000 random
// (reserve_x, reserve_y, amount) tuples and asserts the GPU output is
// byte-equal to the in-process CPU oracle. We use a fixed seed so a failure
// reproduces with the index printed.
func TestBatchEvalConstantProduct_GPUMatchesCPU(t *testing.T) {
const N = 1000
r := rand.New(rand.NewSource(1))
reserves := make([]ReservePair, N)
amounts := make([]uint64, N)
for i := 0; i < N; i++ {
// Keep reserves in [1, 2^48) and amount in [0, 2^32) so
// amount*reserve_y fits in 80 bits — well inside the kernel's
// 128-bit numerator path but big enough to exercise the
// non-trivial divide branch.
reserves[i] = ReservePair{
ReserveX: r.Uint64()%(1<<48) + 1,
ReserveY: r.Uint64()%(1<<48) + 1,
}
amounts[i] = r.Uint64() % (1 << 32)
}
cpu, err := BatchEvalConstantProductCPU(reserves, amounts)
if err != nil {
t.Fatalf("CPU oracle: %v", err)
}
gpu, err := BatchEvalConstantProduct(reserves, amounts)
if err != nil {
t.Fatalf("GPU batch: %v", err)
}
if len(cpu) != N || len(gpu) != N {
t.Fatalf("unexpected output lengths cpu=%d gpu=%d", len(cpu), len(gpu))
}
for i := 0; i < N; i++ {
if cpu[i] != gpu[i] {
t.Fatalf("mismatch at %d: rx=%d ry=%d a=%d cpu=%d gpu=%d",
i, reserves[i].ReserveX, reserves[i].ReserveY,
amounts[i], cpu[i], gpu[i])
}
}
}
// TestBatchEvalConstantProduct_Edge covers the known degenerate inputs:
// zero reserves, zero amount, very small pools.
func TestBatchEvalConstantProduct_Edge(t *testing.T) {
reserves := []ReservePair{
{ReserveX: 0, ReserveY: 0},
{ReserveX: 1, ReserveY: 1},
{ReserveX: 1_000_000, ReserveY: 1_000_000},
{ReserveX: 0, ReserveY: 100},
}
amounts := []uint64{0, 1, 1_000_000, 0}
want := []uint64{
0,
1 / 2,
(1_000_000 * 1_000_000) / (1_000_000 + 1_000_000),
0,
}
got, err := BatchEvalConstantProduct(reserves, amounts)
if err != nil {
t.Fatalf("BatchEvalConstantProduct: %v", err)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %d, want %d", i, got[i], want[i])
}
}
}
// TestBatchEvalConstantProduct_LengthMismatch verifies the input contract.
func TestBatchEvalConstantProduct_LengthMismatch(t *testing.T) {
if _, err := BatchEvalConstantProduct(
[]ReservePair{{1, 1}, {2, 2}},
[]uint64{1},
); err == nil {
t.Fatal("expected error for length mismatch, got nil")
}
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !cgo || !darwin
package lx
// BatchEvalConstantProduct on non-Apple / non-cgo builds runs the CPU
// oracle. The Metal-backed implementation is in amm_gpu.go.
func BatchEvalConstantProduct(reserves []ReservePair, amounts []uint64) ([]uint64, error) {
return BatchEvalConstantProductCPU(reserves, amounts)
}
+5
View File
@@ -54,6 +54,11 @@ type Backend int
const (
BackendGo Backend = iota
// BackendGPU opt-in flag for the GPU acceleration paths in
// signed_order_gpu.go (batch ecrecover) and amm_gpu.go (batch xy=k).
// The matching engine still runs the Go path; selecting BackendGPU
// only affects calls that explicitly check `currentBackend`.
BackendGPU
)
var (
+81
View File
@@ -0,0 +1,81 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package lx
import (
"encoding/binary"
"errors"
"math"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
)
// SignedOrder is an Order plus an attached secp256k1 signature and the
// caller-claimed sender address. The signature covers SigningHash(); the
// expected verification is "ecrecover(SigningHash, Sig) == Sender".
//
// Sig is the standard Ethereum (r||s||v) layout where v ∈ {0, 1}.
type SignedOrder struct {
Order
Sig [65]byte
Sender common.Address
}
// SigningHash is a deterministic 32-byte digest of the order fields that the
// signature covers. The fields are packed in a fixed order with fixed widths
// so the digest is identical on every host. We deliberately keep the encoding
// minimal — IDs, symbol, side/type, price (PriceInt), size (uint64 ticks),
// sender, and the user-provided ClientID — anything beyond that is not part
// of the auth surface for the GPU-batched ingestion path.
//
// The encoding is:
//
// uint64 ID
// uint16 len(Symbol) || Symbol
// uint8 Side
// uint8 Type
// int64 PriceInt(Price) // overflow → returns error
// uint64 SizeTicks(Size) // size * PriceMultiplier (overflow → err)
// uint16 len(ClientID) || ClientID
// 20 Sender
func (o *SignedOrder) SigningHash() ([32]byte, error) {
priceInt, err := safePriceToInt(o.Price)
if err != nil {
return [32]byte{}, err
}
if o.Size < 0 || o.Size > MaxSafePrice {
return [32]byte{}, errors.New("size out of range for SigningHash")
}
sizeTicks := uint64(math.Round(o.Size * PriceMultiplier))
buf := make([]byte, 0, 8+2+len(o.Symbol)+1+1+8+8+2+len(o.ClientID)+20)
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], o.ID)
buf = append(buf, u8[:]...)
binary.BigEndian.PutUint16(u8[:2], uint16(len(o.Symbol)))
buf = append(buf, u8[:2]...)
buf = append(buf, o.Symbol...)
buf = append(buf, byte(o.Side))
buf = append(buf, byte(o.Type))
binary.BigEndian.PutUint64(u8[:], uint64(priceInt))
buf = append(buf, u8[:]...)
binary.BigEndian.PutUint64(u8[:], sizeTicks)
buf = append(buf, u8[:]...)
binary.BigEndian.PutUint16(u8[:2], uint16(len(o.ClientID)))
buf = append(buf, u8[:2]...)
buf = append(buf, o.ClientID...)
buf = append(buf, o.Sender[:]...)
var out [32]byte
copy(out[:], crypto.Keccak256(buf))
return out, nil
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package lx
import (
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
)
// verifyOrdersCPU is the per-order ecrecover oracle used by the test as the
// reference and as the fallback for non-cgo / non-darwin builds. For each
// order it recomputes the signing hash, recovers the pubkey from the sig,
// derives the 20-byte address and compares with order.Sender.
func verifyOrdersCPU(orders []SignedOrder) ([]bool, error) {
out := make([]bool, len(orders))
for i := range orders {
hash, err := orders[i].SigningHash()
if err != nil {
out[i] = false
continue
}
pub, err := crypto.Ecrecover(hash[:], orders[i].Sig[:])
if err != nil || len(pub) != 65 {
out[i] = false
continue
}
// pub is the uncompressed pubkey (0x04 || X || Y). Address is
// last 20 bytes of keccak256(X || Y).
addr := common.BytesToAddress(crypto.Keccak256(pub[1:])[12:])
out[i] = addr == orders[i].Sender
}
return out, nil
}
+137
View File
@@ -0,0 +1,137 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo
package lx
// GPU/batched signed-order verification.
//
// The C primitive lives in luxcpp/crypto:
// header: ~/work/luxcpp/crypto/include/lux/crypto/secp256k1.h
// body: ~/work/luxcpp/crypto/secp256k1/cpp/ecrecover.cpp
// metal: ~/work/luxcpp/crypto/secp256k1/gpu/metal/secp256k1_recover.metal
//
// The CPU/oracle entry point used here is
// `secp256k1_ecrecover_address_batch`: it takes (hash, r||s||v) tuples and
// writes 20-byte Ethereum addresses out. We then compare each recovered
// address with order.Sender and emit the per-order bool.
//
// Build the linked archives once via:
//
// cmake -S ~/work/luxcpp/crypto -B ~/work/luxcpp/crypto/build && \
// cmake --build ~/work/luxcpp/crypto/build \
// --target secp256k1_cpu keccak_cpu
/*
#cgo CFLAGS: -I${SRCDIR}/../../../../luxcpp/crypto/include
#cgo LDFLAGS: -L${SRCDIR}/../../../../luxcpp/crypto/build/secp256k1
#cgo LDFLAGS: -L${SRCDIR}/../../../../luxcpp/crypto/build/keccak
#cgo LDFLAGS: -lsecp256k1_cpu -lkeccak_cpu -lstdc++
//
// Metal driver: dispatch in cpp/ecrecover.cpp resolves the GPU symbol via
// dlsym(RTLD_DEFAULT, ...). The anchor file signed_order_metal_anchor_darwin.c
// takes its address so the linker pulls libsecp256k1_metal.a into the final
// binary. Set LUX_SECP256K1_METALLIB to enable GPU dispatch; unset or
// LUX_SECP256K1_BACKEND=cpu uses the CPU pipeline.
#cgo darwin LDFLAGS: -lsecp256k1_metal -framework Metal -framework Foundation
#include <stdint.h>
#include <stddef.h>
#include "lux/crypto/secp256k1.h"
*/
import "C"
import (
"fmt"
"runtime"
"unsafe"
)
// BatchVerifyOrders verifies a batch of SignedOrders in a single cgo dispatch
// into the luxcpp/crypto secp256k1 batch ecrecover pipeline. Returns a slice
// of bools the same length as orders; out[i]=true means
// ecrecover(SigningHash(orders[i]), orders[i].Sig) == orders[i].Sender.
//
// A returned error means the batch primitive itself failed (bad arguments,
// linker mismatch); per-order rejection is conveyed via out[i]=false rather
// than as an error, so a single bad sig in a 10k batch does not abort the
// other 9,999 verifications.
func BatchVerifyOrders(orders []SignedOrder) ([]bool, error) {
n := len(orders)
if n == 0 {
return nil, nil
}
// Stage 1: Go-side prep — pack hashes (n*32) and sigs (n*65) for the C
// kernel. SigningHash failure flips that order's slot to "skip" so we
// don't feed nonsense into the recovery and falsely match someone.
hashes := make([]byte, n*32)
sigs := make([]byte, n*65)
skip := make([]bool, n)
for i := range orders {
h, err := orders[i].SigningHash()
if err != nil {
skip[i] = true
continue
}
copy(hashes[i*32:(i+1)*32], h[:])
copy(sigs[i*65:(i+1)*65], orders[i].Sig[:])
// Reject obviously malformed v up front (the C kernel only
// accepts {0,1}); marking it skipped here avoids a per-tuple
// error code we'd then have to translate back.
if v := orders[i].Sig[64]; v > 1 {
skip[i] = true
}
}
// Stage 2: single cgo dispatch. We pin the four base pointers for the
// duration of the call — same pattern as cevm.BatchRecoverSenders.
addrs := make([]byte, n*20)
st := make([]byte, n)
var pinner runtime.Pinner
defer pinner.Unpin()
pinner.Pin(&hashes[0])
pinner.Pin(&sigs[0])
pinner.Pin(&addrs[0])
pinner.Pin(&st[0])
rc := C.secp256k1_ecrecover_address_batch(
C.size_t(n),
(*C.uint8_t)(unsafe.Pointer(&hashes[0])),
(*C.uint8_t)(unsafe.Pointer(&sigs[0])),
(*C.uint8_t)(unsafe.Pointer(&addrs[0])),
(*C.uint8_t)(unsafe.Pointer(&st[0])),
)
runtime.KeepAlive(hashes)
runtime.KeepAlive(sigs)
runtime.KeepAlive(addrs)
runtime.KeepAlive(st)
if rc != C.SECP256K1_OK {
return nil, fmt.Errorf("lx: secp256k1_ecrecover_address_batch returned %d", int(rc))
}
// Stage 3: walk per-tuple status + compare recovered address with the
// caller-claimed Sender. status != 0 means the kernel rejected this
// signature (malformed r/s/v, point at infinity, etc.) — that's an
// invalid order, not a batch error.
out := make([]bool, n)
for i := 0; i < n; i++ {
if skip[i] || st[i] != 0 {
out[i] = false
continue
}
ok := true
for j := 0; j < 20; j++ {
if addrs[i*20+j] != orders[i].Sender[j] {
ok = false
break
}
}
out[i] = ok
}
return out, nil
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// Anchor for the secp256k1 Metal driver symbol. See cpp/ecrecover.cpp in
// luxcpp/crypto for the dispatch logic that resolves this symbol via dlsym.
#include <stddef.h>
#include <stdint.h>
extern int secp256k1_ecrecover_address_batch_metal(
const uint8_t* inputs, size_t n,
uint8_t* out_addr, uint8_t* out_st, const char* metallib_path);
// External, visible global: prevents the linker from DCE'ing the reference.
// The address comparison is unconditional so the optimizer cannot fold it
// out as a constant.
void* const lux_secp256k1_metal_anchor =
(void*)&secp256k1_ecrecover_address_batch_metal;
@@ -0,0 +1,35 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo && darwin
package lx
/*
#include <stddef.h>
// Defined in signed_order_metal_anchor_darwin.c.
extern void* const lux_secp256k1_metal_anchor;
// Returning the anchor's value forces the linker to keep both the anchor
// global and (transitively) the secp256k1_ecrecover_address_batch_metal
// symbol it points at — which is what the dispatch in cpp/ecrecover.cpp
// dlsym's at runtime.
static inline void* lux_secp256k1_metal_anchor_keep(void) {
return lux_secp256k1_metal_anchor;
}
*/
import "C"
// metalAnchorAddr is referenced by package init below so the entire chain
// (Go reference -> C inline -> anchor global -> Metal driver symbol) survives
// linker DCE.
var metalAnchorAddr = C.lux_secp256k1_metal_anchor_keep()
func init() {
// Touch the address so the optimizer/linker cannot DCE it.
if metalAnchorAddr == nil {
// unreachable on darwin builds where the metal archive is linked.
_ = metalAnchorAddr
}
}
+13
View File
@@ -0,0 +1,13 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !cgo
package lx
// BatchVerifyOrders verifies a batch of SignedOrders, returning a per-order
// validity bool. On non-cgo builds we run the per-order CPU oracle; the
// luxcpp-backed batch path is in signed_order_gpu.go.
func BatchVerifyOrders(orders []SignedOrder) ([]bool, error) {
return verifyOrdersCPU(orders)
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package lx
import (
"crypto/ecdsa"
"fmt"
"testing"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
)
// makeSignedOrder is a test helper that builds a SignedOrder with a fresh
// secp256k1 keypair, fills the public Order fields with deterministic-ish
// values from id, signs SigningHash() with the freshly generated key, and
// stamps Sender with the corresponding address.
func makeSignedOrder(t testing.TB, id uint64) (SignedOrder, *ecdsa.PrivateKey) {
t.Helper()
key, err := crypto.GenerateKey()
if err != nil {
t.Fatalf("generate key: %v", err)
}
// crypto.PubkeyToAddress returns crypto/common.Address (a sibling type
// to geth/common.Address). The dex package universally uses
// geth/common.Address, so we build it directly from the pubkey bytes:
// addr = last 20 bytes of keccak256(uncompressed_pubkey[1:]).
pub := crypto.FromECDSAPub(&key.PublicKey)
var addr common.Address
copy(addr[:], crypto.Keccak256(pub[1:])[12:])
so := SignedOrder{
Order: Order{
ID: id,
Symbol: "BTC-USD",
Type: Limit,
Side: Buy,
Price: 100 + float64(id%100)/10,
Size: 1 + float64(id%5),
ClientID: fmt.Sprintf("c-%d", id),
},
Sender: addr,
}
hash, err := so.SigningHash()
if err != nil {
t.Fatalf("signing hash: %v", err)
}
sig, err := crypto.Sign(hash[:], key)
if err != nil {
t.Fatalf("sign: %v", err)
}
if len(sig) != 65 {
t.Fatalf("sig len = %d", len(sig))
}
copy(so.Sig[:], sig)
return so, key
}
// TestBatchVerifyOrders_AllValid: 64 random orders, each signed by its own
// fresh key, all 64 must verify true.
func TestBatchVerifyOrders_AllValid(t *testing.T) {
const N = 64
orders := make([]SignedOrder, N)
for i := 0; i < N; i++ {
orders[i], _ = makeSignedOrder(t, uint64(i+1))
}
got, err := BatchVerifyOrders(orders)
if err != nil {
t.Fatalf("BatchVerifyOrders: %v", err)
}
if len(got) != N {
t.Fatalf("len(got) = %d, want %d", len(got), N)
}
for i, ok := range got {
if !ok {
t.Errorf("orders[%d] expected true, got false", i)
}
}
}
// TestBatchVerifyOrders_OneBad: one order in the middle has its r-component
// flipped. That index must come back false; the rest still true.
func TestBatchVerifyOrders_OneBad(t *testing.T) {
const N = 64
const bad = 17
orders := make([]SignedOrder, N)
for i := 0; i < N; i++ {
orders[i], _ = makeSignedOrder(t, uint64(i+1))
}
// Flip a high bit in r — guaranteed to break signature recovery.
orders[bad].Sig[0] ^= 0x80
got, err := BatchVerifyOrders(orders)
if err != nil {
t.Fatalf("BatchVerifyOrders: %v", err)
}
for i, ok := range got {
want := i != bad
if ok != want {
t.Errorf("orders[%d] = %v, want %v", i, ok, want)
}
}
}
// TestBatchVerifyOrders_Empty: zero-length input must round-trip without
// touching the C kernel.
func TestBatchVerifyOrders_Empty(t *testing.T) {
got, err := BatchVerifyOrders(nil)
if err != nil {
t.Fatalf("BatchVerifyOrders(nil): %v", err)
}
if got != nil {
t.Fatalf("got = %v, want nil", got)
}
}
+112
View File
@@ -4,9 +4,48 @@ import (
"fmt"
"testing"
"github.com/luxfi/crypto"
"github.com/luxfi/dex/pkg/lx"
"github.com/luxfi/geth/common"
)
// mustSignOrder builds a SignedOrder with a fresh secp256k1 keypair, signs
// the SigningHash digest, and stamps the derived address as Sender.
// Used by BenchmarkBatchVerifyOrders.
func mustSignOrder(b testing.TB, id uint64) lx.SignedOrder {
b.Helper()
key, err := crypto.GenerateKey()
if err != nil {
b.Fatalf("generate key: %v", err)
}
pub := crypto.FromECDSAPub(&key.PublicKey)
var addr common.Address
copy(addr[:], crypto.Keccak256(pub[1:])[12:])
so := lx.SignedOrder{
Order: lx.Order{
ID: id,
Symbol: "BTC-USD",
Type: lx.Limit,
Side: lx.Buy,
Price: 100 + float64(id%100)/10,
Size: 1 + float64(id%5),
ClientID: fmt.Sprintf("c-%d", id),
},
Sender: addr,
}
hash, err := so.SigningHash()
if err != nil {
b.Fatalf("signing hash: %v", err)
}
sig, err := crypto.Sign(hash[:], key)
if err != nil {
b.Fatalf("sign: %v", err)
}
copy(so.Sig[:], sig)
return so
}
// BenchmarkOrderBook benchmarks order processing
func BenchmarkOrderBook(b *testing.B) {
ob := lx.NewOrderBook("BENCH-USD")
@@ -62,6 +101,79 @@ func BenchmarkOrderBookParallel(b *testing.B) {
b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N), "ns/order")
}
// BenchmarkBatchVerifyOrders compares per-order CPU ecrecover against the
// GPU-batched path on 10k orders. The CPU side is the same per-order oracle
// the test uses as a reference; the "GPU" side calls the batch entry point
// (luxcpp/crypto cgo dispatch when cgo is enabled, otherwise the CPU oracle).
func BenchmarkBatchVerifyOrders(b *testing.B) {
const N = 10_000
// Build the corpus once outside the timer — signing 10k orders with
// fresh keys takes seconds and would dominate every iteration.
orders := make([]lx.SignedOrder, N)
for i := 0; i < N; i++ {
orders[i] = mustSignOrder(b, uint64(i+1))
}
b.Run("CPU_PerOrder", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Single-element batches → forces per-order dispatch.
for j := range orders {
if _, err := lx.BatchVerifyOrders(orders[j : j+1]); err != nil {
b.Fatal(err)
}
}
}
b.ReportMetric(float64(b.N*N)/b.Elapsed().Seconds(), "verifies/sec")
})
b.Run("GPU_Batch", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := lx.BatchVerifyOrders(orders); err != nil {
b.Fatal(err)
}
}
b.ReportMetric(float64(b.N*N)/b.Elapsed().Seconds(), "verifies/sec")
})
}
// BenchmarkBatchAMM compares a Go for-loop CPU evaluation against the
// GPU-batched amm_xyk_batch_metal dispatch on 100k tuples.
func BenchmarkBatchAMM(b *testing.B) {
const N = 100_000
reserves := make([]lx.ReservePair, N)
amounts := make([]uint64, N)
for i := 0; i < N; i++ {
reserves[i] = lx.ReservePair{
ReserveX: uint64(1+i) * 1_000_000,
ReserveY: uint64(2+i) * 500_000,
}
amounts[i] = uint64(1 + i%10_000)
}
b.Run("CPU_Loop", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := lx.BatchEvalConstantProductCPU(reserves, amounts); err != nil {
b.Fatal(err)
}
}
b.ReportMetric(float64(b.N*N)/b.Elapsed().Seconds(), "evals/sec")
})
b.Run("GPU_Batch", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := lx.BatchEvalConstantProduct(reserves, amounts); err != nil {
b.Fatal(err)
}
}
b.ReportMetric(float64(b.N*N)/b.Elapsed().Seconds(), "evals/sec")
})
}
// LP-108: BenchmarkMLXEngine and BenchmarkPlanetScale removed.
//
// BenchmarkMLXEngine pretended to measure an MLX-accelerated engine