mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user