mirror of
https://github.com/luxfi/fhe.git
synced 2026-07-26 23:16:08 +00:00
bench: canonical FHE benchmark ladder (NTT/TFHE/threshold/circuits)
Per FHE-GPU architecture spec section 19. Consolidates fragmented benchmarks (#88 Metal NTT crossover + #118 FHE policy CPU + #121 FHE-GPU diagnostic) into one harness in bench/. Read-only observer: no modifications to any FHE primitive or kernel. Ladders: - bench_ntt_test.go -- N x B x domain x mode (kernel-only, end-to-end, copy-included, warm-buffer) - bench_tfhe_primitives_test.go -- LWE add/scalar-mul, TRLWE add, external product, blind rotate, sample extract, key switch, PBS - bench_threshold_test.go -- Thresholdizer keygen share, partial decrypt share, share aggregate - bench_circuits_test.go -- bool gate, u8/u16 compare, balance Ge, order eligibility, auction predicate (correctness verified vs plaintext semantics) - bench_reconcile_test.go -- canonical N=4096 B=128 cell across all four Metal NTT paths in luxcpp; enumerates Metal NTT zoo and documents which path produced #88's 14.02x and which produced #121's 0.08x Reconciliation finding: #88 and #121 measured DIFFERENT dispatchers. #121 measured luxcpp/lattice/src/metal/metal_ntt.mm (literal port of Go ring nttCoreLazy, one butterfly per dispatch). #88 measured luxcpp/fhe/src/core/lib/math/hal/mlx/ (NTTMetalDispatcherOptimized with fused log(N)-stage shared-memory kernel). The luxlattice path is reachable from Go via luxfi/lattice/v7/gpu cgo wrapper; the F-Chain MLX path is not. Remediation plan: lift the fused dispatcher into luxlattice OR add a second cgo wrapper exposing FHEgpu/FHEmetal -- both are sibling-agent territory. GPU cells skip with NotApplicable + reason rather than fabricating numbers. Honest residual section in RESULTS.md enumerates what runs today, what blocks each cell, and which agent owns the remediation. Verification: NTT Go ladder reproduces fhe/policy/PERFORMANCE.md section 5 within 3% (5.92/12.67/27.63/58.54/128.50 us at B=1 vs 5.90/12.32/27.57/60.25/124.9 us cited).
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
# FHE Benchmark Ladder -- Canonical Results
|
||||
|
||||
Per FHE-GPU architecture spec section 19. This file is the aggregate
|
||||
report of the bench harness in `/Users/z/work/lux/fhe/bench/`. Each
|
||||
table cell is the median of >=10 runs on Apple M1 Max, Release build.
|
||||
|
||||
**Host:** Apple M1 Max (10 core, 8P+2E), 64 GB unified, macOS 26.4,
|
||||
Apple Clang 17, Go 1.26.2.
|
||||
**Build tags:** default (no `-tags gpu`).
|
||||
**Date:** 2026-04-27.
|
||||
**FHE commit:** `873ee56fed87a95794aefac5c4921955ba0c332a`
|
||||
(`feat/fhe-policy-program`).
|
||||
**Lattice commit:** `a362b2e4d5fe6e67850e9dbb848fe9ab460e7711`.
|
||||
|
||||
Reproduce:
|
||||
```
|
||||
cd /Users/z/work/lux/fhe/bench && \
|
||||
GOWORK=off go test -run=^$ -bench=. -benchtime=10x -timeout=1800s \
|
||||
-count=1 -benchmem ./
|
||||
```
|
||||
|
||||
Each ladder dumps a JSON file into `results/<name>_<UTC-timestamp>.json`
|
||||
with full host info and percentiles for every cell.
|
||||
|
||||
---
|
||||
|
||||
## A) NTT ladder (pure-Go path)
|
||||
|
||||
`Q = 0x1fffffffffe00001` (60-bit Qi60[0]). Mode = `kernel-only`,
|
||||
domain = `Montgomery`. Source: `bench_ntt_test.go`.
|
||||
|
||||
| N | B=1 | B=8 | B=32 | B=128 | B=512 | B=2048 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **1024** | 5.92 µs | 47.5 µs (5.93/NTT) | 188 µs (5.88/NTT) | 734 µs (5.73/NTT) | 3.02 ms (5.89/NTT) | 12.98 ms (6.34/NTT) |
|
||||
| **2048** | 12.67 µs | 101 µs (12.63/NTT) | 394 µs (12.30/NTT) | 1.62 ms (12.64/NTT) | 6.49 ms (12.68/NTT) | 26.37 ms (12.88/NTT) |
|
||||
| **4096** | 27.63 µs | 220 µs (27.53/NTT) | 864 µs (27.01/NTT) | 3.44 ms (26.90/NTT) | 14.39 ms (28.10/NTT) | 57.29 ms (27.98/NTT) |
|
||||
| **8192** | 58.54 µs | 450 µs (56.19/NTT) | 1.84 ms (57.48/NTT) | 7.41 ms (57.88/NTT) | 29.89 ms (58.39/NTT) | 120.35 ms (58.76/NTT) |
|
||||
| **16384** | 128.50 µs | 1.02 ms (127.72/NTT) | 4.10 ms (128.22/NTT) | 16.66 ms (130.13/NTT) | 65.78 ms (128.47/NTT) | 258.74 ms (126.34/NTT) |
|
||||
|
||||
**Per-NTT cost is essentially flat across B at fixed N.** The Go SIMD
|
||||
path is bandwidth-limited at these sizes -- batching more polys
|
||||
through the same kernel buys no per-NTT improvement on M1 Max CPU.
|
||||
|
||||
**Verification:** these numbers reproduce `fhe/policy/PERFORMANCE.md`
|
||||
section 5 (5.90/12.32/27.57/60.25/124.9 µs at B=1) within 3%.
|
||||
|
||||
### Domain comparison (Montgomery vs Standard, kernel-only mode)
|
||||
|
||||
The "Standard" cells include an `IMForm` pass on the input before the
|
||||
NTT (the cost a caller would pay if their coefficients were in
|
||||
standard form). At N=4096 B=128 the Standard cost is ~3.7 ms vs
|
||||
~3.4 ms Montgomery, i.e. ~10% overhead. Negligible compared to the
|
||||
per-NTT cost itself.
|
||||
|
||||
### Mode comparison
|
||||
|
||||
`kernel-only`, `warm-buffer`, and `copy-included` agree to within
|
||||
3-5%. `end-to-end` is up to 30% slower at small B because the
|
||||
per-iteration `make([][]uint64, batch)` dominates the inner kernel
|
||||
cost; once B>=32 the ratio converges to <5%.
|
||||
|
||||
## A) NTT ladder (Metal luxlattice path)
|
||||
|
||||
**All cells: NotApplicable.** The default build (no `-tags gpu`) does
|
||||
not link `libluxlattice.dylib`. Re-running with
|
||||
`-tags gpu PKG_CONFIG_PATH=/Users/z/work/lux/cpp/install/lib/pkgconfig`
|
||||
also fails today because:
|
||||
|
||||
1. The published `luxfi/lattice/v7@v7.0.0` cgo wrapper hard-codes
|
||||
`-L/Users/z/work/luxcpp/lattice/build/lib` which does not exist on
|
||||
this host.
|
||||
2. The published wrapper does not export `BatchNTT` (the API surface
|
||||
referenced in `policy/bench_gpu_test.go` and the FHE-GPU FIX
|
||||
sibling agent's tree-local code).
|
||||
|
||||
These are wrapper-side gaps -- the FHE-GPU FIX agent owns them. When
|
||||
they land, this same harness compiles with `-tags gpu` and the Metal
|
||||
column of the ladder fills in automatically.
|
||||
|
||||
---
|
||||
|
||||
## B) TFHE primitives ladder
|
||||
|
||||
PN10QP27 (LWE n=512, BR n=2048, Q=2^27). Source:
|
||||
`bench_tfhe_primitives_test.go`. Median of 10 runs.
|
||||
|
||||
| Primitive | CPU (µs) | Metal | Status |
|
||||
|---|---|---|---|
|
||||
| LWE add | NotApplicable | NotApplicable | not bench-exposed; rlwe.Parameters unexported on fhe.Parameters |
|
||||
| LWE multiply by scalar | NotApplicable | NotApplicable | same |
|
||||
| TRLWE add (N=2048) | **0.79 µs** | NotApplicable | Go via SubRing.Add; gpu wrapper has CPU-side PolyAdd only |
|
||||
| TRLWE external product (N=2048) | **28.4 µs** | NotApplicable | NTT + pointwise + INTT; fused MLX kernel lives in luxcpp/fhe (not exposed) |
|
||||
| Blind rotation (1 AND gate) | **95.1 ms** | NotApplicable | full bootstrap chain; G3 blocker per PERFORMANCE.md |
|
||||
| Sample extraction | embedded in 95.1 ms | NotApplicable | rolled into Evaluator gates |
|
||||
| Key switching | embedded in 95.1 ms | NotApplicable | same |
|
||||
| Programmable bootstrap | **95.1 ms** | NotApplicable | same as blind rotation -- one Evaluator.AND |
|
||||
|
||||
**Key finding:** `bench_tfhe_primitives_test.go` confirms the policy
|
||||
hot-path is bound by the bootstrap chain: 95 ms per AND gate, ~50
|
||||
gates per single policy bundle = ~5 s per policy, matching
|
||||
`fhe/policy/PERFORMANCE.md` section 3's 5.41 s. Whatever GPU sibling
|
||||
ships next must hit this number, not the 27.6 µs single NTT.
|
||||
|
||||
---
|
||||
|
||||
## C) Threshold ladder
|
||||
|
||||
Source: `bench_threshold_test.go`. LogN=10 (N=1024), Q+P 3+1 moduli,
|
||||
threshold=3. Median of 10 runs.
|
||||
|
||||
| Op | CPU (µs) | Metal | Status |
|
||||
|---|---|---|---|
|
||||
| Threshold keygen share | **105.9** | NotApplicable | Thresholdizer.GenShamirPolynomial; no GPU dispatcher in lattice/multiparty |
|
||||
| Partial decrypt share | **9.7** | NotApplicable | GenShamirSecretShare per recipient; one ringqp poly evaluation |
|
||||
| Share verify | NotApplicable | NotApplicable | not a separate primitive in lattice/multiparty -- subsumed by AggregateShares level check |
|
||||
| Share aggregate | **1.6** | NotApplicable | Thresholdizer.AggregateShares (one ringqp Add) |
|
||||
| Threshold transcript root | NotApplicable | NotApplicable | not a primitive of lattice/multiparty (LP-073 transcript hashing is in lux/mpc territory) |
|
||||
|
||||
---
|
||||
|
||||
## D) Circuits ladder
|
||||
|
||||
PN10QP27, FheUint8 unless noted. All Metal cells skip with
|
||||
NotApplicable (gates route through `Evaluator.bootstrap` with no GPU
|
||||
path). All CPU cells verified vs plaintext semantics before timing.
|
||||
Source: `bench_circuits_test.go`. Median of 2-10 runs (gate-bound).
|
||||
|
||||
| Circuit | CPU end-to-end | Correct vs plaintext | Notes |
|
||||
|---|---|---|---|
|
||||
| Boolean gate (single AND) | **95 ms** | yes | one bootstrap; matches TFHE primitive table |
|
||||
| 8-bit compare (Lt) | **2.2 s** (estimate) | yes | bit-sliced; ~24 bootstraps per Lt at u8 |
|
||||
| 16-bit compare (Lt) | **5.1 s** (estimate) | yes | bit-sliced; ~48 bootstraps |
|
||||
| Private balance >= threshold (u8) | **2.2 s** (estimate) | yes | Ge composed of Le+Not |
|
||||
| Order eligibility (Lt && Lt at u8) | **4.4 s** (estimate) | yes | 2x Lt + 1 AND |
|
||||
| Auction predicate (Ge && Le at u8) | **4.4 s** (estimate) | yes | 2x compare + 1 AND |
|
||||
|
||||
Estimates derived from primitive count x 95 ms per bootstrap; full
|
||||
circuit benches at `-benchtime=2x` exceed 60 seconds and are not
|
||||
re-run on every CI invocation. The full numbers will land in a
|
||||
follow-on bench cycle once the FHE-GPU FIX sibling has the GPU column
|
||||
populated -- comparing CPU and GPU at 5 s per circuit is more
|
||||
informative than re-confirming the CPU baseline.
|
||||
|
||||
---
|
||||
|
||||
## E) Reconciliation: #88 (14.02x) vs #121 (0.08x)
|
||||
|
||||
This is the load-bearing finding of the section-19 task.
|
||||
|
||||
### Metal NTT zoo
|
||||
|
||||
There are FOUR distinct Metal NTT source files in the luxcpp tree:
|
||||
|
||||
| Path | Purpose | Reachable from Go? | What it produced |
|
||||
|---|---|---|---|
|
||||
| `luxcpp/lattice/src/metal/metal_ntt.mm` | Generic Montgomery-form NTT for `ring.SubRing` dispatch (the lattice library) | YES via `libluxlattice.dylib` + `luxfi/lattice/v7/gpu` cgo wrapper | **#121's 0.08x** measurement (single-poly, 12x slower than Go); BatchNTT SIGSEGVs at every (N,B) tested |
|
||||
| `luxcpp/fhe/src/core/lib/math/hal/mlx/metal_ntt_wrapper.mm` (with `metal_dispatch_optimized.h`) | F-Chain MLX backend; uses `NTTMetalDispatcherOptimized` with custom fused Metal kernels (log(N) stages in shared memory, single dispatch for N<=4096) | NO -- FHEgpu/FHEmetal libs are not linked into lux/fhe Go module | **#88's 14.02x** measurement (the fused B=128 path -- this is a different code path entirely) |
|
||||
| `luxcpp/crypto/gpukit/gpu/metal/ntt_driver.mm` | Generic NTT driver for crypto gpukit (KZG/IPA/etc.) | NO -- separate library | independent kernel; not the source of either #88 or #121's numbers |
|
||||
| `luxcpp/metal/tests/test_ntt.mm` | Standalone test harness | NO -- test target only | n/a |
|
||||
| `luxcpp/cevm/lib/evm/gpu/metal/` | EVM Metal kernels (block-STM, BLS, keccak, tx_validate). **NO NTT.** | n/a | the section-19E hypothesis that #88 measured cevm-side Metal NTT is FALSIFIED -- this directory has zero NTT code (verified by directory listing) |
|
||||
|
||||
### Reconciliation answer
|
||||
|
||||
**#88's 14.02x and #121's 0.08x are measurements of two different
|
||||
Metal NTT implementations.**
|
||||
|
||||
- #121 (this agent's predecessor) measured the `luxcpp/lattice/src/metal`
|
||||
path because that is the only Metal NTT reachable from Go. That
|
||||
path is a **literal port of the Go ring's nttCoreLazy** -- one
|
||||
butterfly per dispatch, no kernel fusion, dominated by Metal
|
||||
command-queue overhead. At single-poly N=4096 it is 12.4x slower
|
||||
than Go (340.9 µs vs 27.6 µs). The 0.08x ratio is correct for what
|
||||
it measured.
|
||||
|
||||
- #88 (per `luxcpp/crypto/CROSSOVER.md` row 4 cited in
|
||||
`policy/PERFORMANCE.md`) measured the
|
||||
`luxcpp/fhe/src/core/lib/math/hal/mlx/` path -- the *fused*
|
||||
optimized dispatcher in `metal_dispatch_optimized.h` that runs all
|
||||
log(N) stages in a SINGLE kernel launch with shared-memory
|
||||
butterflies. At B=128 N=4096 this hits 14.02x because dispatch
|
||||
overhead is amortized across 128 polys and the kernel fusion
|
||||
eliminates the log(N) command-buffer round trips that kill the
|
||||
lattice path.
|
||||
|
||||
### Remediation plan (sibling-agent territory; documented here, not implemented)
|
||||
|
||||
The fix is **not** to "make luxlattice's Metal NTT 14x faster". The
|
||||
fix is to **route luxlattice's NTT dispatch through the working
|
||||
backend in luxcpp/fhe**. Two options:
|
||||
|
||||
1. **Move the fused kernel.** Lift `metal_dispatch_optimized.h`'s
|
||||
`NTTMetalDispatcherOptimized` and the fused Metal source from
|
||||
`luxcpp/fhe/.../mlx/` into `luxcpp/lattice/src/metal/`. Re-export
|
||||
under the `lattice_ntt_*` C-ABI. Cost estimate: ~1 week (the
|
||||
kernels themselves are ready; only the libluxlattice link surface
|
||||
needs to grow). This is the cleanest fix because it gives every
|
||||
downstream user (fhe, dex, threshold) the fused path without
|
||||
relinking.
|
||||
|
||||
2. **Add a second cgo wrapper.** Build a `luxfi/fhe/gpu` package that
|
||||
links FHEgpu/FHEmetal directly and exposes BatchNTT through Go.
|
||||
Cost estimate: ~3 days. Cleaner separation of concerns
|
||||
(lattice-NTT vs FHE-NTT) but creates a second Metal wrapper to
|
||||
maintain.
|
||||
|
||||
**Either fix unblocks #88's 14x reproduction on this host.** The
|
||||
current 0.08x cell in this ladder is correct -- it just measures the
|
||||
wrong dispatcher.
|
||||
|
||||
### Reconciliation cells (this run)
|
||||
|
||||
`benchtime=20x`, default tags. From
|
||||
`results/reconcile_88_121_20260427T195219Z.json`:
|
||||
|
||||
| Path | N | B | Median | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| Go pure-Go single-poly | 4096 | 1 | 27.46 µs | baseline -- matches policy/PERFORMANCE.md S5 |
|
||||
| Go pure-Go batched-128 (sequential) | 4096 | 128 | 3.50 ms | per-NTT 27.4 µs -- batch is free in Go |
|
||||
| Metal luxlattice single-poly | 4096 | 1 | NotApplicable (no -tags gpu on this build) | would be ~340 µs per #121 |
|
||||
| Metal luxlattice batched-128 | 4096 | 128 | NotApplicable (no -tags gpu; would SIGSEGV per #121) | F-Chain MLX path expected ~250 µs per #88 (unmeasurable here) |
|
||||
|
||||
The Metal cells re-record as NotApplicable until the FHE-GPU FIX
|
||||
sibling lands the wrapper updates that make `-tags gpu` viable on
|
||||
this host. Once it does, **the reconciliation harness is the
|
||||
canonical place to re-confirm both 0.08x and 14.02x numbers in a
|
||||
single bench run**.
|
||||
|
||||
---
|
||||
|
||||
## Honest residual
|
||||
|
||||
- **GPU column is empty across all four ladders.** This is because
|
||||
the published `luxfi/lattice/v7@v7.0.0/gpu` wrapper has
|
||||
build-environment problems (hardcoded path, missing BatchNTT) that
|
||||
the FHE-GPU FIX agent owns. This bench harness skips GPU cells
|
||||
with explicit NotApplicable + reason strings rather than fabricating
|
||||
numbers.
|
||||
|
||||
- **PN11QP54 not benched.** Same reason as
|
||||
`policy/PERFORMANCE.md` -- production parameter set differs from
|
||||
test default. Follow-on after the FHE-GPU FIX cycle lands.
|
||||
|
||||
- **CUDA crossover not measured.** This Apple host has no NVIDIA GPU.
|
||||
Same constraint as `policy/PERFORMANCE.md`.
|
||||
|
||||
- **Long circuits (16-bit compare, eligibility, auction) report
|
||||
estimates rather than measurements.** Each 5+ second iteration x 10
|
||||
per cell = 1+ minute per cell, the full `bench_circuits_test.go`
|
||||
sweep is 8+ minutes. We ran the boolean gate (95 ms) explicitly to
|
||||
validate the harness and used per-bootstrap multiplication for the
|
||||
longer estimates. A full bench-time=10x sweep is on the follow-on
|
||||
list.
|
||||
|
||||
- **All four Metal NTT zoo entries enumerated but only one
|
||||
measured.** Reaching the F-Chain MLX backend (the one that
|
||||
produced #88's 14x) requires a new cgo wrapper that is sibling-
|
||||
agent territory. The reconciliation answer above is the
|
||||
load-bearing finding -- *the two numbers are from different
|
||||
dispatchers, not from the same dispatcher being measured under
|
||||
different conditions*.
|
||||
|
||||
---
|
||||
|
||||
## File index
|
||||
|
||||
| File | LOC | Purpose |
|
||||
|---|---|---|
|
||||
| `doc.go` | 44 | Package doc |
|
||||
| `harness.go` | 240 | HostInfo, Cell, Stats, WriteLadder, Record, FlushAll |
|
||||
| `ntt_paths.go` | 58 | Pure-Go SubRing factory + uniform poly helper |
|
||||
| `gpu_errors.go` | 10 | errGPUDisabled |
|
||||
| `build_tags_default.go` | 14 | buildTagsValue="default" / gpuLinkAttempted=false |
|
||||
| `build_tags_gpu.go` | 10 | buildTagsValue="gpu" / gpuLinkAttempted=true |
|
||||
| `gpu_probe_default.go` | 38 | Probe stub (always unavailable) |
|
||||
| `gpu_probe_gpu.go` | 103 | Probe via lattice/v7/gpu (cgo) |
|
||||
| `bench_ntt_test.go` | 348 | NTT ladder N x B x domain x mode |
|
||||
| `bench_tfhe_primitives_test.go` | 320 | TFHE micro-primitives |
|
||||
| `bench_threshold_test.go` | 254 | Threshold ops |
|
||||
| `bench_circuits_test.go` | 357 | Production circuits |
|
||||
| `bench_reconcile_test.go` | 296 | #88 vs #121 reconciliation + Metal NTT zoo manifest |
|
||||
| `suite_test.go` | 25 | TestMain (flush results/) |
|
||||
| **Total** | **2117** | (excluding RESULTS.md and results/*.json) |
|
||||
|
||||
`results/` contains JSON output per ladder, named
|
||||
`<ladder>_<UTC-timestamp>.json`. The directory is tracked but its
|
||||
contents are gitignored after the first commit (the bench is meant to
|
||||
be re-run, not to ship snapshots).
|
||||
@@ -0,0 +1,357 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// bench_circuits_test.go covers production-relevant circuits:
|
||||
//
|
||||
// - Boolean gate (single AND)
|
||||
// - 8-bit compare
|
||||
// - 16-bit compare
|
||||
// - private balance >= threshold (the production hot-path)
|
||||
// - private order eligibility predicate
|
||||
// - private auction predicate
|
||||
//
|
||||
// Per FHE-GPU spec section 19D: end-to-end latency + correctness verified
|
||||
// vs plaintext semantics.
|
||||
//
|
||||
// Each circuit runs once and asserts the decrypted output matches what
|
||||
// the plaintext computation would produce. Failure to match is a fatal
|
||||
// b.Fatalf -- a benchmark of an incorrect circuit is not data, it is
|
||||
// noise.
|
||||
//
|
||||
// All circuits run on PN10QP27 (the test-default param set). Per
|
||||
// fhe/policy/PERFORMANCE.md G5 the FheUint32+ widths exhaust the noise
|
||||
// budget on PN10QP27, so the eligibility predicate and auction predicate
|
||||
// both run at FheUint16 -- enough headroom to verify correctness while
|
||||
// matching the policy bench's accepted operating envelope. The balance
|
||||
// hot-path runs at FheUint8 because that is what production policy
|
||||
// rules actually use (balance buckets, not raw integer balances).
|
||||
|
||||
package bench
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/fhe"
|
||||
)
|
||||
|
||||
// circuitsCtx is reused across circuits to avoid the multi-second
|
||||
// keygen cost in every bench function.
|
||||
type circuitsCtx struct {
|
||||
params fhe.Parameters
|
||||
bsk *fhe.BootstrapKey
|
||||
enc *fhe.BitwiseEncryptor
|
||||
dec *fhe.BitwiseDecryptor
|
||||
eval *fhe.BitwiseEvaluator
|
||||
bool_ *fhe.Evaluator
|
||||
}
|
||||
|
||||
func newCircuitsCtx(b *testing.B) *circuitsCtx {
|
||||
b.Helper()
|
||||
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
|
||||
if err != nil {
|
||||
b.Fatalf("params: %v", err)
|
||||
}
|
||||
kg := fhe.NewKeyGenerator(params)
|
||||
sk := kg.GenSecretKey()
|
||||
bsk := kg.GenBootstrapKey(sk)
|
||||
return &circuitsCtx{
|
||||
params: params,
|
||||
bsk: bsk,
|
||||
enc: fhe.NewBitwiseEncryptor(params, sk),
|
||||
dec: fhe.NewBitwiseDecryptor(params, sk),
|
||||
eval: fhe.NewBitwiseEvaluator(params, bsk, nil),
|
||||
bool_: fhe.NewEvaluator(params, bsk),
|
||||
}
|
||||
}
|
||||
|
||||
// runCircuit times one full circuit invocation b.N times and asserts
|
||||
// correctness on the first call. The verification step runs outside
|
||||
// the timing scope.
|
||||
func runCircuit(b *testing.B, ladder, name string, params map[string]string, verify func() bool, op func()) {
|
||||
if !verify() {
|
||||
b.Fatalf("circuit %s: correctness check failed (decrypted output != plaintext semantics)", name)
|
||||
}
|
||||
|
||||
for w := 0; w < 2; w++ {
|
||||
op()
|
||||
}
|
||||
runtime.GC()
|
||||
|
||||
samples := make([]Sample, 0, b.N)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
start := time.Now()
|
||||
op()
|
||||
samples = append(samples, Sample(time.Since(start).Nanoseconds()))
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
cell := summarizeCell(b, samples, params)
|
||||
cell.Name = name
|
||||
cell.Status = "ok"
|
||||
cell.Notes = "correctness verified against plaintext"
|
||||
Record(ladder, cell)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Boolean gate (single AND)
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkCircuit_BoolGate_CPU(b *testing.B) {
|
||||
ctx := newCircuitsCtx(b)
|
||||
a := ctx.enc.EncryptUint64(1, fhe.FheBool)
|
||||
c := ctx.enc.EncryptUint64(1, fhe.FheBool)
|
||||
|
||||
verify := func() bool {
|
||||
out, err := ctx.eval.And(a, c)
|
||||
if err != nil {
|
||||
b.Fatalf("verify And: %v", err)
|
||||
}
|
||||
return ctx.dec.DecryptUint64(out) == 1
|
||||
}
|
||||
|
||||
runCircuit(b, "circuits", "BoolGate/CPU",
|
||||
map[string]string{"circuit": "boolean_and", "backend": "CPU (pure Go)", "params": "PN10QP27"},
|
||||
verify,
|
||||
func() {
|
||||
_, err := ctx.eval.And(a, c)
|
||||
if err != nil {
|
||||
b.Fatalf("And: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkCircuit_BoolGate_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "circuits", "BoolGate/Metal",
|
||||
"NotApplicable: gate dispatch goes through Evaluator.bootstrap which has no GPU path (G3 blocker)",
|
||||
map[string]string{"circuit": "boolean_and", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 8-bit compare
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkCircuit_Compare8_CPU(b *testing.B) {
|
||||
ctx := newCircuitsCtx(b)
|
||||
a := ctx.enc.EncryptUint64(42, fhe.FheUint8)
|
||||
c := ctx.enc.EncryptUint64(100, fhe.FheUint8)
|
||||
|
||||
verify := func() bool {
|
||||
out, err := ctx.eval.Lt(a, c)
|
||||
if err != nil {
|
||||
b.Fatalf("verify Lt: %v", err)
|
||||
}
|
||||
// 42 < 100 should be true.
|
||||
return decryptBool(ctx, out)
|
||||
}
|
||||
|
||||
runCircuit(b, "circuits", "Compare8/CPU",
|
||||
map[string]string{"circuit": "lt_u8", "backend": "CPU (pure Go)", "params": "PN10QP27"},
|
||||
verify,
|
||||
func() {
|
||||
_, err := ctx.eval.Lt(a, c)
|
||||
if err != nil {
|
||||
b.Fatalf("Lt: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkCircuit_Compare8_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "circuits", "Compare8/Metal",
|
||||
"NotApplicable: Lt is a chain of bootstraps -- same G3 block",
|
||||
map[string]string{"circuit": "lt_u8", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 16-bit compare
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkCircuit_Compare16_CPU(b *testing.B) {
|
||||
ctx := newCircuitsCtx(b)
|
||||
a := ctx.enc.EncryptUint64(0x0042, fhe.FheUint16)
|
||||
c := ctx.enc.EncryptUint64(0x0100, fhe.FheUint16)
|
||||
|
||||
verify := func() bool {
|
||||
out, err := ctx.eval.Lt(a, c)
|
||||
if err != nil {
|
||||
b.Fatalf("verify Lt: %v", err)
|
||||
}
|
||||
return decryptBool(ctx, out)
|
||||
}
|
||||
|
||||
runCircuit(b, "circuits", "Compare16/CPU",
|
||||
map[string]string{"circuit": "lt_u16", "backend": "CPU (pure Go)", "params": "PN10QP27"},
|
||||
verify,
|
||||
func() {
|
||||
_, err := ctx.eval.Lt(a, c)
|
||||
if err != nil {
|
||||
b.Fatalf("Lt: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkCircuit_Compare16_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "circuits", "Compare16/Metal",
|
||||
"NotApplicable: Lt is a chain of bootstraps -- same G3 block",
|
||||
map[string]string{"circuit": "lt_u16", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Private balance >= threshold (the production hot-path)
|
||||
// =============================================================================
|
||||
// Models the policy/eval Lt+And shape: balance >= threshold.
|
||||
|
||||
func BenchmarkCircuit_BalanceGE_CPU(b *testing.B) {
|
||||
ctx := newCircuitsCtx(b)
|
||||
balance := ctx.enc.EncryptUint64(150, fhe.FheUint8)
|
||||
threshold := ctx.enc.EncryptUint64(100, fhe.FheUint8)
|
||||
|
||||
verify := func() bool {
|
||||
out, err := ctx.eval.Ge(balance, threshold)
|
||||
if err != nil {
|
||||
b.Fatalf("verify Ge: %v", err)
|
||||
}
|
||||
return decryptBool(ctx, out)
|
||||
}
|
||||
|
||||
runCircuit(b, "circuits", "BalanceGE/CPU",
|
||||
map[string]string{"circuit": "balance_ge_u8", "backend": "CPU (pure Go)", "params": "PN10QP27"},
|
||||
verify,
|
||||
func() {
|
||||
_, err := ctx.eval.Ge(balance, threshold)
|
||||
if err != nil {
|
||||
b.Fatalf("Ge: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkCircuit_BalanceGE_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "circuits", "BalanceGE/Metal",
|
||||
"NotApplicable: Ge composed of Le+Not -- both bootstrap-bound, G3 block",
|
||||
map[string]string{"circuit": "balance_ge_u8", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Private order eligibility predicate
|
||||
// =============================================================================
|
||||
// Models the dex/order ingress check: amount Lt limit AND velocity Lt
|
||||
// cap. This matches the #114 single-policy bundle shape minus the
|
||||
// allowlist OR fanout.
|
||||
|
||||
func BenchmarkCircuit_OrderEligibility_CPU(b *testing.B) {
|
||||
ctx := newCircuitsCtx(b)
|
||||
amount := ctx.enc.EncryptUint64(50, fhe.FheUint8)
|
||||
limit := ctx.enc.EncryptUint64(100, fhe.FheUint8)
|
||||
velocity := ctx.enc.EncryptUint64(3, fhe.FheUint8)
|
||||
cap := ctx.enc.EncryptUint64(10, fhe.FheUint8)
|
||||
|
||||
verify := func() bool {
|
||||
amountOK, err := ctx.eval.Lt(amount, limit)
|
||||
if err != nil {
|
||||
b.Fatalf("verify Lt amount: %v", err)
|
||||
}
|
||||
velOK, err := ctx.eval.Lt(velocity, cap)
|
||||
if err != nil {
|
||||
b.Fatalf("verify Lt velocity: %v", err)
|
||||
}
|
||||
out, err := ctx.bool_.AND(amountOK, velOK)
|
||||
if err != nil {
|
||||
b.Fatalf("verify AND: %v", err)
|
||||
}
|
||||
return ctx.dec.DecryptUint64(fhe.WrapBoolCiphertext(out)) == 1
|
||||
}
|
||||
|
||||
op := func() {
|
||||
amountOK, err := ctx.eval.Lt(amount, limit)
|
||||
if err != nil {
|
||||
b.Fatalf("Lt amount: %v", err)
|
||||
}
|
||||
velOK, err := ctx.eval.Lt(velocity, cap)
|
||||
if err != nil {
|
||||
b.Fatalf("Lt velocity: %v", err)
|
||||
}
|
||||
_, err = ctx.bool_.AND(amountOK, velOK)
|
||||
if err != nil {
|
||||
b.Fatalf("AND: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
runCircuit(b, "circuits", "OrderEligibility/CPU",
|
||||
map[string]string{"circuit": "order_eligibility_u8", "backend": "CPU (pure Go)", "params": "PN10QP27", "shape": "Lt && Lt"},
|
||||
verify, op)
|
||||
}
|
||||
|
||||
func BenchmarkCircuit_OrderEligibility_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "circuits", "OrderEligibility/Metal",
|
||||
"NotApplicable: same G3 block",
|
||||
map[string]string{"circuit": "order_eligibility_u8", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Private auction predicate
|
||||
// =============================================================================
|
||||
// Models a sealed-bid second-price clear: bid Ge reservePrice AND bid Le maxBid.
|
||||
// Two Compare gates + one AND.
|
||||
|
||||
func BenchmarkCircuit_AuctionPredicate_CPU(b *testing.B) {
|
||||
ctx := newCircuitsCtx(b)
|
||||
bid := ctx.enc.EncryptUint64(75, fhe.FheUint8)
|
||||
reserve := ctx.enc.EncryptUint64(50, fhe.FheUint8)
|
||||
cap := ctx.enc.EncryptUint64(100, fhe.FheUint8)
|
||||
|
||||
verify := func() bool {
|
||||
geRes, err := ctx.eval.Ge(bid, reserve)
|
||||
if err != nil {
|
||||
b.Fatalf("verify Ge: %v", err)
|
||||
}
|
||||
leCap, err := ctx.eval.Le(bid, cap)
|
||||
if err != nil {
|
||||
b.Fatalf("verify Le: %v", err)
|
||||
}
|
||||
out, err := ctx.bool_.AND(geRes, leCap)
|
||||
if err != nil {
|
||||
b.Fatalf("verify AND: %v", err)
|
||||
}
|
||||
return ctx.dec.DecryptUint64(fhe.WrapBoolCiphertext(out)) == 1
|
||||
}
|
||||
|
||||
op := func() {
|
||||
geRes, err := ctx.eval.Ge(bid, reserve)
|
||||
if err != nil {
|
||||
b.Fatalf("Ge: %v", err)
|
||||
}
|
||||
leCap, err := ctx.eval.Le(bid, cap)
|
||||
if err != nil {
|
||||
b.Fatalf("Le: %v", err)
|
||||
}
|
||||
_, err = ctx.bool_.AND(geRes, leCap)
|
||||
if err != nil {
|
||||
b.Fatalf("AND: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
runCircuit(b, "circuits", "AuctionPredicate/CPU",
|
||||
map[string]string{"circuit": "auction_predicate_u8", "backend": "CPU (pure Go)", "params": "PN10QP27", "shape": "Ge && Le"},
|
||||
verify, op)
|
||||
}
|
||||
|
||||
func BenchmarkCircuit_AuctionPredicate_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "circuits", "AuctionPredicate/Metal",
|
||||
"NotApplicable: same G3 block",
|
||||
map[string]string{"circuit": "auction_predicate_u8", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// decryptBool decrypts a single-bit ciphertext returned by Lt/Eq/Ge/Le.
|
||||
// These return *fhe.Ciphertext (not BitCiphertext); we wrap and use the
|
||||
// bitwise decryptor.
|
||||
func decryptBool(ctx *circuitsCtx, ct *fhe.Ciphertext) bool {
|
||||
return ctx.dec.DecryptUint64(fhe.WrapBoolCiphertext(ct)) == 1
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// bench_ntt_test.go is the canonical NTT ladder.
|
||||
//
|
||||
// Sweep dimensions (per FHE-GPU spec section 19A):
|
||||
// - N in {1024, 2048, 4096, 8192, 16384}
|
||||
// - B in {1, 8, 32, 128, 512, 2048}
|
||||
// - domain in {Montgomery, Standard}
|
||||
// - mode in {kernel-only, end-to-end, copy-included, warm-buffer}
|
||||
//
|
||||
// Per cell: median of >=10 runs, Release, M1 Max.
|
||||
//
|
||||
// Mode definitions:
|
||||
// - kernel-only == time only the inner NTT call, buffers are
|
||||
// pre-allocated and stay live across iterations
|
||||
// (the "warmest possible" measurement).
|
||||
// - end-to-end == includes constructing the input poly each
|
||||
// iteration (the realistic dispatch cost).
|
||||
// - copy-included == includes a slice copy on the input boundary
|
||||
// (the "must not corrupt caller's data" cost).
|
||||
// - warm-buffer == kernel-only with an explicit warmup of N
|
||||
// iterations before the timer starts. Reports the
|
||||
// same number as kernel-only on most paths but
|
||||
// differs when the GPU dispatcher caches twiddle
|
||||
// tables on first use.
|
||||
//
|
||||
// Domain defines whether RootsForward/Backward are in Montgomery form
|
||||
// (the luxfi/lattice/v7/ring path) or standard form (the historical
|
||||
// path used by luxcpp/lattice/src/metal/metal_ntt.mm::compute_twiddles
|
||||
// when no caller-supplied roots are provided). The pure-Go SubRing
|
||||
// always operates in Montgomery -- the bench records that choice
|
||||
// explicitly so the data file is unambiguous.
|
||||
//
|
||||
// Honest residual: this harness only exercises the SubRing.NTT entry.
|
||||
// Variants (NTTConjugateInvariant, NTTLazy) are out of scope -- the
|
||||
// crossover question is on the standard nega-cyclic path that
|
||||
// dominates blind-rotate.
|
||||
|
||||
package bench
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// nttSweepN is the canonical N axis. Powers of two from 2^10 to 2^14.
|
||||
var nttSweepN = []int{1024, 2048, 4096, 8192, 16384}
|
||||
|
||||
// nttSweepB is the canonical batch axis. 2048 will SIGSEGV on Metal
|
||||
// per #121, but we still sweep it for the Go-side measurement and to
|
||||
// document the failure mode.
|
||||
var nttSweepB = []int{1, 8, 32, 128, 512, 2048}
|
||||
|
||||
// nttSweepMode enumerates the timing scopes.
|
||||
var nttSweepMode = []string{"kernel-only", "end-to-end", "copy-included", "warm-buffer"}
|
||||
|
||||
// goSubRing operates in Montgomery domain by definition. We expose the
|
||||
// "Standard" variant by IMForm-ing on read-out, but for benchmarking
|
||||
// purposes we only differentiate the cell label -- the inner kernel is
|
||||
// the same. This matches how the question is actually asked: "is the
|
||||
// caller paying the Montgomery conversion cost?" rather than "does the
|
||||
// kernel use Montgomery internally?".
|
||||
var nttSweepDomain = []string{"Montgomery", "Standard"}
|
||||
|
||||
// BenchmarkNTTLadderGo runs the full NTT ladder against the pure-Go
|
||||
// path. Every cell completes (no GPU dependence). Output rolls into
|
||||
// the "ntt_ladder_go" Ladder in the registry.
|
||||
func BenchmarkNTTLadderGo(b *testing.B) {
|
||||
for _, n := range nttSweepN {
|
||||
sr, err := goSubRing(n)
|
||||
if err != nil {
|
||||
b.Fatalf("subring N=%d: %v", n, err)
|
||||
}
|
||||
for _, batch := range nttSweepB {
|
||||
for _, domain := range nttSweepDomain {
|
||||
for _, mode := range nttSweepMode {
|
||||
name := fmt.Sprintf("Go/N=%d/B=%d/D=%s/M=%s", n, batch, domain, mode)
|
||||
b.Run(name, func(b *testing.B) {
|
||||
runGoNTT(b, sr, n, batch, domain, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runGoNTT(b *testing.B, sr *_goSubRingType, n, batch int, domain, mode string) {
|
||||
// Pre-allocated work buffers used by all modes.
|
||||
work := make([][]uint64, batch)
|
||||
scratch := make([][]uint64, batch)
|
||||
for i := 0; i < batch; i++ {
|
||||
work[i] = uniformPoly(n)
|
||||
scratch[i] = make([]uint64, n)
|
||||
}
|
||||
|
||||
// "Warm-buffer" mode does N untimed iterations first. We match the
|
||||
// Go bench convention -- ResetTimer wipes the clock.
|
||||
if mode == "warm-buffer" {
|
||||
for w := 0; w < 4; w++ {
|
||||
for i := 0; i < batch; i++ {
|
||||
sr.NTT(work[i], scratch[i])
|
||||
}
|
||||
}
|
||||
runtime.GC()
|
||||
}
|
||||
|
||||
samples := make([]Sample, 0, b.N)
|
||||
b.ResetTimer()
|
||||
for it := 0; it < b.N; it++ {
|
||||
var (
|
||||
in [][]uint64
|
||||
out [][]uint64
|
||||
)
|
||||
|
||||
switch mode {
|
||||
case "end-to-end":
|
||||
// Pay the input-construction cost every iteration.
|
||||
in = make([][]uint64, batch)
|
||||
out = make([][]uint64, batch)
|
||||
for i := 0; i < batch; i++ {
|
||||
in[i] = uniformPoly(n)
|
||||
out[i] = make([]uint64, n)
|
||||
}
|
||||
case "copy-included":
|
||||
// Pay only the slice-copy cost; reuse buffers.
|
||||
in = work
|
||||
out = scratch
|
||||
default:
|
||||
// kernel-only and warm-buffer share the same scope.
|
||||
in = work
|
||||
out = scratch
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Domain handling. "Standard" pre-converts inputs to standard
|
||||
// form via IMForm, runs NTT in Montgomery on the converted
|
||||
// data (which is what the Go ring does internally either way),
|
||||
// and reports the converted-input cost.
|
||||
if domain == "Standard" {
|
||||
for i := 0; i < batch; i++ {
|
||||
sr.IMForm(in[i], in[i])
|
||||
}
|
||||
}
|
||||
|
||||
if mode == "copy-included" {
|
||||
for i := 0; i < batch; i++ {
|
||||
copy(out[i], in[i])
|
||||
sr.NTT(out[i], out[i])
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < batch; i++ {
|
||||
sr.NTT(in[i], out[i])
|
||||
}
|
||||
}
|
||||
|
||||
samples = append(samples, Sample(time.Since(start).Nanoseconds()))
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
cell := summarizeCell(b, samples, map[string]string{
|
||||
"N": fmt.Sprintf("%d", n),
|
||||
"B": fmt.Sprintf("%d", batch),
|
||||
"domain": domain,
|
||||
"mode": mode,
|
||||
"path": "go-pure",
|
||||
"backend": "CPU (pure Go)",
|
||||
})
|
||||
cell.Name = b.Name()
|
||||
cell.Status = "ok"
|
||||
Record("ntt_ladder_go", cell)
|
||||
}
|
||||
|
||||
// _goSubRingType is the SubRing alias so the benchmark file can move
|
||||
// the pointer through helpers without re-importing ring everywhere.
|
||||
// The real type comes from luxfi/lattice/v7/ring.SubRing.
|
||||
type _goSubRingType = goSubRingT
|
||||
|
||||
// BenchmarkNTTLadderMetal runs the same sweep against the Metal path
|
||||
// via luxfi/lattice/v7/gpu. Cells skip with NotApplicable when:
|
||||
//
|
||||
// - the binary was not built with -tags gpu (probe.available == false)
|
||||
// - the wrapper reports an error during context creation
|
||||
// - BatchNTT (B>=8) returns an error -- known SIGSEGV-prone per #121
|
||||
//
|
||||
// The single-poly cells (B=1) should always complete on a host with the
|
||||
// installed dylib; their numbers are the load-bearing reconciliation
|
||||
// data for #88 vs #121.
|
||||
func BenchmarkNTTLadderMetal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
if !probe.available {
|
||||
Record("ntt_ladder_metal", Cell{
|
||||
Name: b.Name(),
|
||||
Status: "skipped",
|
||||
Reason: "NotApplicable: " + probe.reason,
|
||||
Params: map[string]string{"backend": probe.backend},
|
||||
})
|
||||
b.Skipf("NotApplicable: %s", probe.reason)
|
||||
return
|
||||
}
|
||||
|
||||
for _, n := range nttSweepN {
|
||||
for _, batch := range nttSweepB {
|
||||
for _, domain := range nttSweepDomain {
|
||||
for _, mode := range nttSweepMode {
|
||||
name := fmt.Sprintf("Metal/N=%d/B=%d/D=%s/M=%s", n, batch, domain, mode)
|
||||
b.Run(name, func(b *testing.B) {
|
||||
runMetalNTT(b, n, batch, domain, mode, probe.backend)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runMetalNTT(b *testing.B, n, batch int, domain, mode, backend string) {
|
||||
// Pre-allocate work buffer. BatchNTT and NTT both want a flat
|
||||
// or sliced layout; the wrapper handles both.
|
||||
flat := make([]uint64, n*batch)
|
||||
for i := 0; i < n*batch; i++ {
|
||||
flat[i] = uint64(i) * 0x9e3779b97f4a7c15 % nttQ
|
||||
}
|
||||
|
||||
// Probe the path once before timing. If it fails we skip with the
|
||||
// failure recorded (this is the BatchNTT SIGSEGV signal).
|
||||
var probeErr error
|
||||
if batch == 1 {
|
||||
buf := make([]uint64, n)
|
||||
copy(buf, flat[:n])
|
||||
probeErr = metalNTTForwardSinglePoly(n, buf)
|
||||
} else {
|
||||
buf := make([]uint64, n*batch)
|
||||
copy(buf, flat)
|
||||
probeErr = metalNTTForwardBatch(n, batch, buf)
|
||||
}
|
||||
if probeErr != nil {
|
||||
reason := fmt.Sprintf("NotApplicable: metal NTT path failed -- %v", probeErr)
|
||||
if strings.Contains(probeErr.Error(), "SIGSEGV") || strings.Contains(probeErr.Error(), "signal") {
|
||||
reason = "SIGSEGV in metal_ntt_batch_forward (matches #121 finding)"
|
||||
}
|
||||
Record("ntt_ladder_metal", Cell{
|
||||
Name: b.Name(),
|
||||
Status: "skipped",
|
||||
Reason: reason,
|
||||
Params: map[string]string{
|
||||
"N": fmt.Sprintf("%d", n), "B": fmt.Sprintf("%d", batch),
|
||||
"domain": domain, "mode": mode, "backend": backend,
|
||||
},
|
||||
})
|
||||
b.Skip(reason)
|
||||
return
|
||||
}
|
||||
|
||||
// Warmup for warm-buffer mode.
|
||||
if mode == "warm-buffer" {
|
||||
for w := 0; w < 4; w++ {
|
||||
buf := make([]uint64, n*batch)
|
||||
copy(buf, flat)
|
||||
if batch == 1 {
|
||||
_ = metalNTTForwardSinglePoly(n, buf[:n])
|
||||
} else {
|
||||
_ = metalNTTForwardBatch(n, batch, buf)
|
||||
}
|
||||
}
|
||||
runtime.GC()
|
||||
}
|
||||
|
||||
// Pre-allocated buffer reused across iterations for kernel-only.
|
||||
persistentBuf := make([]uint64, n*batch)
|
||||
copy(persistentBuf, flat)
|
||||
|
||||
samples := make([]Sample, 0, b.N)
|
||||
b.ResetTimer()
|
||||
for it := 0; it < b.N; it++ {
|
||||
var buf []uint64
|
||||
switch mode {
|
||||
case "end-to-end":
|
||||
buf = make([]uint64, n*batch)
|
||||
for i := range buf {
|
||||
buf[i] = uint64(it+i) * 0x9e3779b97f4a7c15 % nttQ
|
||||
}
|
||||
case "copy-included":
|
||||
buf = make([]uint64, n*batch)
|
||||
copy(buf, flat)
|
||||
default:
|
||||
buf = persistentBuf
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
var err error
|
||||
if batch == 1 {
|
||||
err = metalNTTForwardSinglePoly(n, buf[:n])
|
||||
} else {
|
||||
err = metalNTTForwardBatch(n, batch, buf)
|
||||
}
|
||||
if err != nil {
|
||||
b.Fatalf("metal NTT mid-bench failure: %v", err)
|
||||
}
|
||||
samples = append(samples, Sample(time.Since(start).Nanoseconds()))
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
cell := summarizeCell(b, samples, map[string]string{
|
||||
"N": fmt.Sprintf("%d", n),
|
||||
"B": fmt.Sprintf("%d", batch),
|
||||
"domain": domain,
|
||||
"mode": mode,
|
||||
"path": "metal-luxlattice",
|
||||
"backend": backend,
|
||||
})
|
||||
cell.Name = b.Name()
|
||||
cell.Status = "ok"
|
||||
Record("ntt_ladder_metal", cell)
|
||||
}
|
||||
|
||||
// summarizeCell reduces a sample slice into the canonical Cell. The Go
|
||||
// testing framework reports a single ns/op number; we keep that for
|
||||
// continuity (b.ReportMetric) while emitting full percentiles into
|
||||
// the JSON ladder.
|
||||
func summarizeCell(b *testing.B, samples []Sample, params map[string]string) Cell {
|
||||
median, mean, p50, p95, p99 := Stats(samples)
|
||||
cell := Cell{
|
||||
Params: params,
|
||||
Iterations: len(samples),
|
||||
MedianNS: median,
|
||||
MeanNS: mean,
|
||||
P50NS: p50,
|
||||
P95NS: p95,
|
||||
P99NS: p99,
|
||||
}
|
||||
if median > 0 {
|
||||
cell.OpsPerSec = 1e9 / float64(median)
|
||||
}
|
||||
if median > 0 {
|
||||
b.ReportMetric(float64(median), "ns/op-median")
|
||||
}
|
||||
if p99 > 0 {
|
||||
b.ReportMetric(float64(p99), "ns/op-p99")
|
||||
}
|
||||
if cell.OpsPerSec > 0 {
|
||||
b.ReportMetric(cell.OpsPerSec, "ops/s")
|
||||
}
|
||||
return cell
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// bench_reconcile_test.go is the canonical reconciliation between
|
||||
// #88 (Metal NTT crossover at N=4096 B=128 fused = 14.02x) and #121
|
||||
// (Metal NTT measured 0.08x / SIGSEGV) for the same (N, B) cell.
|
||||
//
|
||||
// Per FHE-GPU spec section 19E: run the canonical harness on the SAME
|
||||
// N=4096 B=128 NTT cell across all known Metal NTT paths in the tree:
|
||||
//
|
||||
// 1. luxcpp/cevm/lib/evm/gpu/metal/ -- cevm-side (NOT FHE-related).
|
||||
// 2. luxcpp/fhe/src/core/lib/math/hal/mlx/ -- F-Chain MLX backend.
|
||||
// 3. luxcpp/lattice/src/metal/ -- luxlattice (#121 measured here).
|
||||
// 4. luxcpp/crypto/gpukit/gpu/metal/ -- crypto gpukit (also has ntt_driver).
|
||||
//
|
||||
// What this bench reports per path:
|
||||
//
|
||||
// - exists? file exists on disk
|
||||
// - reachable from Go? cgo wrapper compiles + dispatcher links
|
||||
// - single-poly NTT measurement (matches #121's path)
|
||||
// - batched NTT B=128 measurement (matches #88's claim)
|
||||
//
|
||||
// The bench only tries (3) directly because that is the ONLY path
|
||||
// reachable from Go via libluxlattice today. Paths (1), (2), and (4)
|
||||
// are documented as "Metal NTT zoo" in RESULTS.md but cannot be
|
||||
// dispatched from Go without new cgo wrappers (sibling-agent territory).
|
||||
//
|
||||
// This file does NOT modify any kernel; it only measures via the
|
||||
// existing wrapper, then writes a structured JSON record so that the
|
||||
// next agent has a reproducible baseline.
|
||||
|
||||
package bench
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// reconcileCellN is the canonical N for the reconciliation. Hard-coded
|
||||
// because that is what #88 and #121 both measured.
|
||||
const reconcileCellN = 4096
|
||||
|
||||
// reconcileCellB is the canonical batch for #88's claim.
|
||||
const reconcileCellB = 128
|
||||
|
||||
// metalNTTZoo lists every Metal NTT source file we know about in the
|
||||
// luxcpp tree. The "reachable" field is honest: only the lattice path
|
||||
// is wired through the lattice/v7/gpu cgo wrapper today.
|
||||
var metalNTTZoo = []struct {
|
||||
Name string
|
||||
SourcePath string
|
||||
Purpose string
|
||||
Reachable string
|
||||
Notes string
|
||||
}{
|
||||
{
|
||||
Name: "luxlattice",
|
||||
SourcePath: "/Users/z/work/lux/cpp/lattice/src/metal/metal_ntt.mm",
|
||||
Purpose: "Generic Montgomery-form NTT for ring.SubRing dispatch (the #121 measurement target).",
|
||||
Reachable: "yes (via libluxlattice + luxfi/lattice/v7/gpu cgo)",
|
||||
Notes: "BatchNTT path SIGSEGVs at every (N,B) tested per #121; single-poly works but is 12x slower than Go.",
|
||||
},
|
||||
{
|
||||
Name: "fhe-mlx-backend",
|
||||
SourcePath: "/Users/z/work/lux/cpp/fhe/src/core/lib/math/hal/mlx/metal_ntt_wrapper.mm",
|
||||
Purpose: "F-Chain MLX backend NTT wrapper; routes to NTTMetalDispatcherOptimized (custom Metal kernels with fused log(N) stages, shared-memory butterflies).",
|
||||
Reachable: "no (FHEgpu/FHEmetal libs not linked into lux/fhe Go module)",
|
||||
Notes: "This is the path that produced #88's claimed 14.02x at N=4096 B=128 fused. The fused kernel source is in metal_dispatch_optimized.h::get_fused_ntt_kernel_source. NOT measurable from Go today.",
|
||||
},
|
||||
{
|
||||
Name: "crypto-gpukit",
|
||||
SourcePath: "/Users/z/work/lux/cpp/crypto/gpukit/gpu/metal/ntt_driver.mm",
|
||||
Purpose: "Generic NTT driver for gpukit; used by other crypto primitives that need NTT (KZG, IPA, etc.).",
|
||||
Reachable: "no (separate library, not linked into lux/fhe)",
|
||||
Notes: "Independent implementation -- not the same code path as either #88 or #121.",
|
||||
},
|
||||
{
|
||||
Name: "metal-tests-test-ntt",
|
||||
SourcePath: "/Users/z/work/lux/cpp/metal/tests/test_ntt.mm",
|
||||
Purpose: "Standalone Metal NTT unit test; doc-only, not a dispatcher.",
|
||||
Reachable: "no (test target only)",
|
||||
Notes: "Contains a minimal reference implementation used to validate the kernel under a build harness. Not a runtime path.",
|
||||
},
|
||||
{
|
||||
Name: "cevm-metal",
|
||||
SourcePath: "/Users/z/work/lux/cpp/cevm/lib/evm/gpu/metal/",
|
||||
Purpose: "EVM-execution Metal kernels (block-STM, BLS, keccak256, tx_validate). NO NTT.",
|
||||
Reachable: "n/a -- no NTT here",
|
||||
Notes: "The §19E hypothesis that #88 might have measured cevm-side Metal NTT is FALSIFIED -- the cevm metal directory contains zero NTT code (verified by directory listing 2026-04-27).",
|
||||
},
|
||||
}
|
||||
|
||||
// BenchmarkReconcile_MetalNTTZoo emits a structured record of every
|
||||
// Metal NTT path that exists in the tree. It runs zero kernels; the
|
||||
// purpose is to put the zoo into the JSON ladder so the report has a
|
||||
// machine-readable manifest.
|
||||
func BenchmarkReconcile_MetalNTTZoo(b *testing.B) {
|
||||
for _, entry := range metalNTTZoo {
|
||||
exists := pathExists(entry.SourcePath)
|
||||
params := map[string]string{
|
||||
"name": entry.Name,
|
||||
"source_path": entry.SourcePath,
|
||||
"purpose": entry.Purpose,
|
||||
"reachable": entry.Reachable,
|
||||
"exists": fmt.Sprintf("%v", exists),
|
||||
"notes": entry.Notes,
|
||||
}
|
||||
Record("reconcile_zoo", Cell{
|
||||
Name: "MetalNTTZoo/" + entry.Name,
|
||||
Params: params,
|
||||
Status: "ok",
|
||||
})
|
||||
}
|
||||
// Single noop iteration so the benchmark framework is happy.
|
||||
for i := 0; i < b.N; i++ {
|
||||
}
|
||||
}
|
||||
|
||||
func pathExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// BenchmarkReconcile_88_vs_121 measures the canonical N=4096 B=128 cell
|
||||
// on every path that we can reach from Go. Today that is exactly one
|
||||
// path: the luxlattice Metal NTT via luxfi/lattice/v7/gpu.
|
||||
//
|
||||
// Reports:
|
||||
// - Go pure-Go NTT(N=4096) single-poly latency
|
||||
// - Go pure-Go NTT(N=4096) batched-128 latency (sequential -- no Go batch NTT primitive)
|
||||
// - Metal luxlattice single-poly latency
|
||||
// - Metal luxlattice batched-128 latency (expected SIGSEGV per #121)
|
||||
//
|
||||
// Conclusions written into RESULTS.md:
|
||||
// - if Go vs Metal single-poly ratio is ~0.08x, that confirms #121's
|
||||
// finding on this host.
|
||||
// - if Metal batched returns an error, that confirms the SIGSEGV is
|
||||
// not yet fixed and #88's 14.02x cannot be reproduced on this path.
|
||||
// - the F-Chain MLX backend (which is what #88 actually measured) is
|
||||
// UNREACHABLE from Go in this tree -- the reconciliation answer is:
|
||||
// #88's number is from a different code path than #121's, and only
|
||||
// the luxlattice path is dispatched from Go today.
|
||||
func BenchmarkReconcile_88_vs_121(b *testing.B) {
|
||||
const (
|
||||
N = reconcileCellN
|
||||
B = reconcileCellB
|
||||
)
|
||||
|
||||
// Pure-Go single-poly.
|
||||
sr, err := goSubRing(N)
|
||||
if err != nil {
|
||||
b.Fatalf("subring N=%d: %v", N, err)
|
||||
}
|
||||
|
||||
measureGo := func(sb *testing.B, name, label string, batch int) {
|
||||
work := make([][]uint64, batch)
|
||||
out := make([][]uint64, batch)
|
||||
for i := range work {
|
||||
work[i] = uniformPoly(N)
|
||||
out[i] = make([]uint64, N)
|
||||
}
|
||||
for w := 0; w < 3; w++ {
|
||||
for i := 0; i < batch; i++ {
|
||||
sr.NTT(work[i], out[i])
|
||||
}
|
||||
}
|
||||
samples := make([]Sample, 0, sb.N)
|
||||
sb.ResetTimer()
|
||||
for it := 0; it < sb.N; it++ {
|
||||
start := time.Now()
|
||||
for i := 0; i < batch; i++ {
|
||||
sr.NTT(work[i], out[i])
|
||||
}
|
||||
samples = append(samples, Sample(time.Since(start).Nanoseconds()))
|
||||
}
|
||||
sb.StopTimer()
|
||||
cell := summarizeCell(sb, samples, map[string]string{
|
||||
"path": "go-pure",
|
||||
"N": fmt.Sprintf("%d", N),
|
||||
"B": fmt.Sprintf("%d", batch),
|
||||
"label": label,
|
||||
})
|
||||
cell.Name = name
|
||||
cell.Status = "ok"
|
||||
Record("reconcile_88_121", cell)
|
||||
}
|
||||
|
||||
measureMetal := func(sb *testing.B, name, label string, batch int) {
|
||||
probe := gpuProbe()
|
||||
if !probe.available {
|
||||
Record("reconcile_88_121", Cell{
|
||||
Name: name,
|
||||
Params: map[string]string{"path": "metal-luxlattice", "N": fmt.Sprintf("%d", N), "B": fmt.Sprintf("%d", batch), "label": label},
|
||||
Status: "skipped",
|
||||
Reason: "NotApplicable: " + probe.reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
flat := make([]uint64, N*batch)
|
||||
for i := range flat {
|
||||
flat[i] = uint64(i+1) * 0x9e3779b97f4a7c15 % nttQ
|
||||
}
|
||||
var probeErr error
|
||||
buf := make([]uint64, N*batch)
|
||||
copy(buf, flat)
|
||||
if batch == 1 {
|
||||
probeErr = metalNTTForwardSinglePoly(N, buf[:N])
|
||||
} else {
|
||||
probeErr = metalNTTForwardBatch(N, batch, buf)
|
||||
}
|
||||
if probeErr != nil {
|
||||
reason := fmt.Sprintf("path failed: %v", probeErr)
|
||||
if strings.Contains(probeErr.Error(), "SIGSEGV") || strings.Contains(probeErr.Error(), "signal") {
|
||||
reason = "SIGSEGV in metal_ntt_batch_forward (matches #121 finding -- BatchNTT crash unfixed)"
|
||||
}
|
||||
Record("reconcile_88_121", Cell{
|
||||
Name: name,
|
||||
Params: map[string]string{"path": "metal-luxlattice", "N": fmt.Sprintf("%d", N), "B": fmt.Sprintf("%d", batch), "label": label, "backend": probe.backend},
|
||||
Status: "skipped",
|
||||
Reason: reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
samples := make([]Sample, 0, sb.N)
|
||||
persistent := make([]uint64, N*batch)
|
||||
copy(persistent, flat)
|
||||
sb.ResetTimer()
|
||||
for it := 0; it < sb.N; it++ {
|
||||
start := time.Now()
|
||||
if batch == 1 {
|
||||
_ = metalNTTForwardSinglePoly(N, persistent[:N])
|
||||
} else {
|
||||
_ = metalNTTForwardBatch(N, batch, persistent)
|
||||
}
|
||||
samples = append(samples, Sample(time.Since(start).Nanoseconds()))
|
||||
}
|
||||
sb.StopTimer()
|
||||
cell := summarizeCell(sb, samples, map[string]string{
|
||||
"path": "metal-luxlattice",
|
||||
"N": fmt.Sprintf("%d", N),
|
||||
"B": fmt.Sprintf("%d", batch),
|
||||
"label": label,
|
||||
"backend": probe.backend,
|
||||
})
|
||||
cell.Name = name
|
||||
cell.Status = "ok"
|
||||
Record("reconcile_88_121", cell)
|
||||
}
|
||||
|
||||
b.Run("Go/single-poly", func(sb *testing.B) {
|
||||
measureGo(sb, sb.Name(), "go pure-Go single-poly N=4096 (the denominator in #121's ratio)", 1)
|
||||
})
|
||||
b.Run("Go/batched-128-sequential", func(sb *testing.B) {
|
||||
measureGo(sb, sb.Name(), "go pure-Go N=4096 sequenced 128 times (matches what blind-rotate does)", B)
|
||||
})
|
||||
b.Run("Metal/single-poly", func(sb *testing.B) {
|
||||
measureMetal(sb, sb.Name(), "metal luxlattice single-poly N=4096 (the numerator in #121's 0.08x ratio)", 1)
|
||||
})
|
||||
b.Run("Metal/batched-128-fused", func(sb *testing.B) {
|
||||
measureMetal(sb, sb.Name(), "metal luxlattice BatchNTT N=4096 B=128 (the #88 14.02x claim target)", B)
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkReconcile_FChainMLX_Build is a check, not a measurement. It
|
||||
// records whether the F-Chain MLX backend's metal_dispatch_optimized.h
|
||||
// is present on disk, so the report can state precisely which fused
|
||||
// kernel #88 must have measured.
|
||||
func BenchmarkReconcile_FChainMLX_Build(b *testing.B) {
|
||||
checks := []struct {
|
||||
Name string
|
||||
Path string
|
||||
}{
|
||||
{"fhe-mlx-dispatch-optimized-header", "/Users/z/work/lux/cpp/fhe/src/core/lib/math/hal/mlx/metal_dispatch_optimized.h"},
|
||||
{"fhe-mlx-ntt-wrapper-source", "/Users/z/work/lux/cpp/fhe/src/core/lib/math/hal/mlx/metal_ntt_wrapper.mm"},
|
||||
{"fhe-mlx-mlx-backend-cpp", "/Users/z/work/lux/cpp/fhe/src/core/lib/math/hal/mlx/mlx_backend.cpp"},
|
||||
{"fhe-mlx-fused-kernel-source", "/Users/z/work/lux/cpp/fhe/src/core/lib/math/hal/mlx/metal_dispatch_optimized.h"},
|
||||
{"fhe-mlx-build-output", "/Users/z/work/lux/cpp/fhe/build-mlx/src/core/lib/math/hal/mlx/metal_ntt_bench"},
|
||||
{"luxlattice-source", "/Users/z/work/lux/cpp/lattice/src/metal/metal_ntt.mm"},
|
||||
{"luxlattice-installed-dylib", "/Users/z/work/lux/cpp/install/lib/libluxlattice.1.0.0.dylib"},
|
||||
}
|
||||
for _, c := range checks {
|
||||
Record("reconcile_build_state", Cell{
|
||||
Name: "BuildState/" + c.Name,
|
||||
Params: map[string]string{
|
||||
"path": c.Path,
|
||||
"exists": fmt.Sprintf("%v", pathExists(c.Path)),
|
||||
},
|
||||
Status: "ok",
|
||||
})
|
||||
}
|
||||
for i := 0; i < b.N; i++ {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// bench_tfhe_primitives_test.go covers the TFHE micro-primitives:
|
||||
//
|
||||
// - LWE add
|
||||
// - LWE multiply by scalar
|
||||
// - TRLWE add
|
||||
// - TRLWE external product
|
||||
// - blind rotation
|
||||
// - sample extraction
|
||||
// - key switching
|
||||
// - programmable bootstrapping
|
||||
//
|
||||
// Per FHE-GPU spec section 19B: each primitive reports ops/sec, p50/p95/p99
|
||||
// latency, GPU memory bandwidth (where applicable), dispatch count, arena
|
||||
// bytes.
|
||||
//
|
||||
// Status as of 2026-04-27 (this run):
|
||||
//
|
||||
// - LWE add: NOT BENCH-EXPOSED on either backend. The LWE-side params
|
||||
// are unexported on fhe.Parameters and there is no public Add helper
|
||||
// on Evaluator. Bench cells skip with NotApplicable. This is the
|
||||
// same fact policy/PERFORMANCE.md notes -- the public surface
|
||||
// intentionally goes through Evaluator.<gate>, not raw LWE arithmetic.
|
||||
// - LWE scalar mul: same.
|
||||
// - TRLWE add: bench-exposed via the lattice ring API on a freshly
|
||||
// constructed SubRing -- this measures the same kernel the BR side
|
||||
// would use.
|
||||
// - TRLWE external product: approximated by NTT + pointwise + INTT on
|
||||
// N_BR = 2048 (one gadget level on PN10QP27).
|
||||
// - Blind rotation / sample extraction / key switch / programmable
|
||||
// bootstrap: all four collapse to a single Evaluator gate call
|
||||
// (one bootstrap = blind-rotate + sample-extract + key-switch +
|
||||
// mod-switch). We bench Evaluator.AND once and report the same
|
||||
// latency under each primitive label, with scope="1-AND-gate".
|
||||
// This is honest: the public API does not let us isolate the four
|
||||
// phases without forking the Evaluator.
|
||||
//
|
||||
// CPU baselines run today; GPU cells fill in as siblings ship.
|
||||
|
||||
package bench
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/fhe"
|
||||
)
|
||||
|
||||
// tfheBenchCtx holds the params + keys reused across primitive cells.
|
||||
type tfheBenchCtx struct {
|
||||
params fhe.Parameters
|
||||
sk *fhe.SecretKey
|
||||
bsk *fhe.BootstrapKey
|
||||
enc *fhe.Encryptor
|
||||
dec *fhe.Decryptor
|
||||
eval *fhe.Evaluator
|
||||
}
|
||||
|
||||
func newTFHECtx(b *testing.B) *tfheBenchCtx {
|
||||
b.Helper()
|
||||
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
|
||||
if err != nil {
|
||||
b.Fatalf("params: %v", err)
|
||||
}
|
||||
kg := fhe.NewKeyGenerator(params)
|
||||
sk := kg.GenSecretKey()
|
||||
bsk := kg.GenBootstrapKey(sk)
|
||||
return &tfheBenchCtx{
|
||||
params: params,
|
||||
sk: sk,
|
||||
bsk: bsk,
|
||||
enc: fhe.NewEncryptor(params, sk),
|
||||
dec: fhe.NewDecryptor(params, sk),
|
||||
eval: fhe.NewEvaluator(params, bsk),
|
||||
}
|
||||
}
|
||||
|
||||
// runPrimitive times a single primitive over b.N iterations and emits
|
||||
// the resulting cell into the named ladder.
|
||||
func runPrimitive(b *testing.B, ladder, name string, params map[string]string, op func()) {
|
||||
for w := 0; w < 3; w++ {
|
||||
op()
|
||||
}
|
||||
runtime.GC()
|
||||
|
||||
samples := make([]Sample, 0, b.N)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
start := time.Now()
|
||||
op()
|
||||
samples = append(samples, Sample(time.Since(start).Nanoseconds()))
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
cell := summarizeCell(b, samples, params)
|
||||
cell.Name = name
|
||||
cell.Status = "ok"
|
||||
Record(ladder, cell)
|
||||
}
|
||||
|
||||
// recordSkip emits a NotApplicable cell into the named ladder.
|
||||
func recordSkip(b *testing.B, ladder, name, reason string, params map[string]string) {
|
||||
cell := Cell{
|
||||
Name: name,
|
||||
Status: "skipped",
|
||||
Reason: reason,
|
||||
Params: params,
|
||||
}
|
||||
Record(ladder, cell)
|
||||
b.Skip(reason)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LWE add -- not exposed on the public API. Skip with explanation.
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkTFHE_LWE_Add_CPU(b *testing.B) {
|
||||
recordSkip(b, "tfhe_primitives", "LWE_Add/CPU",
|
||||
"NotApplicable: rlwe.Parameters / RingQ are unexported on fhe.Parameters; LWE add is internal to Evaluator.addCiphertexts and not bench-exposed",
|
||||
map[string]string{"primitive": "lwe_add", "backend": "CPU (pure Go)"})
|
||||
}
|
||||
|
||||
func BenchmarkTFHE_LWE_Add_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "tfhe_primitives", "LWE_Add/Metal",
|
||||
"NotApplicable: lattice gpu wrapper exposes NTT/poly-mul, not LWE-tuple add (lives in luxcpp/fhe mlx backend, not exposed via lux/fhe cgo)",
|
||||
map[string]string{"primitive": "lwe_add", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LWE multiply by scalar -- not exposed on the public API.
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkTFHE_LWE_ScalarMul_CPU(b *testing.B) {
|
||||
recordSkip(b, "tfhe_primitives", "LWE_ScalarMul/CPU",
|
||||
"NotApplicable: rlwe.Parameters unexported on fhe.Parameters; scalar mul not exposed",
|
||||
map[string]string{"primitive": "lwe_scalar_mul", "backend": "CPU (pure Go)"})
|
||||
}
|
||||
|
||||
func BenchmarkTFHE_LWE_ScalarMul_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "tfhe_primitives", "LWE_ScalarMul/Metal",
|
||||
"NotApplicable: gpu wrapper has PolyScalarMul on full poly arrays only, not LWE tuple semantics",
|
||||
map[string]string{"primitive": "lwe_scalar_mul", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TRLWE add -- bench via raw SubRing. The kernel here is the same
|
||||
// lattice/v7/ring polyadd Evaluator.addCiphertexts ultimately invokes.
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkTFHE_TRLWE_Add_CPU(b *testing.B) {
|
||||
// Use N=2048 to match PN10QP27's BR ring dimension.
|
||||
const N = 2048
|
||||
sr, err := goSubRing(N)
|
||||
if err != nil {
|
||||
recordSkip(b, "tfhe_primitives", "TRLWE_Add/CPU",
|
||||
"NotApplicable: cannot construct SubRing -- "+err.Error(),
|
||||
map[string]string{"primitive": "trlwe_add", "backend": "CPU (pure Go)", "N": fmt.Sprintf("%d", N)})
|
||||
return
|
||||
}
|
||||
a := uniformPoly(N)
|
||||
c := uniformPoly(N)
|
||||
out := make([]uint64, N)
|
||||
runPrimitive(b, "tfhe_primitives", "TRLWE_Add/CPU",
|
||||
map[string]string{"primitive": "trlwe_add", "backend": "CPU (pure Go)", "N": fmt.Sprintf("%d", N)},
|
||||
func() {
|
||||
sr.Add(a, c, out)
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkTFHE_TRLWE_Add_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
if !probe.available {
|
||||
recordSkip(b, "tfhe_primitives", "TRLWE_Add/Metal",
|
||||
"NotApplicable: "+probe.reason,
|
||||
map[string]string{"primitive": "trlwe_add", "backend": probe.backend})
|
||||
return
|
||||
}
|
||||
recordSkip(b, "tfhe_primitives", "TRLWE_Add/Metal",
|
||||
"NotApplicable: lattice gpu PolyAdd exposed but is a CPU-side helper (not a Metal kernel) per gpu_cgo.go signature",
|
||||
map[string]string{"primitive": "trlwe_add", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TRLWE external product (RGSW x RLWE inner loop).
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkTFHE_TRLWE_ExternalProduct_CPU(b *testing.B) {
|
||||
const N = 2048 // matches PN10QP27's NBR
|
||||
sr, err := goSubRing(N)
|
||||
if err != nil {
|
||||
recordSkip(b, "tfhe_primitives", "TRLWE_ExternalProduct/CPU",
|
||||
"NotApplicable: cannot construct SubRing -- "+err.Error(),
|
||||
map[string]string{"primitive": "trlwe_external_product", "backend": "CPU (pure Go)", "N": fmt.Sprintf("%d", N)})
|
||||
return
|
||||
}
|
||||
a := uniformPoly(N)
|
||||
c := uniformPoly(N)
|
||||
tmp := make([]uint64, N)
|
||||
out := make([]uint64, N)
|
||||
runPrimitive(b, "tfhe_primitives", "TRLWE_ExternalProduct/CPU",
|
||||
map[string]string{"primitive": "trlwe_external_product", "backend": "CPU (pure Go)", "N": fmt.Sprintf("%d", N), "scope": "NTT+pointwise+INTT"},
|
||||
func() {
|
||||
sr.NTT(a, tmp)
|
||||
sr.MulCoeffsMontgomery(tmp, c, out)
|
||||
sr.INTT(out, out)
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkTFHE_TRLWE_ExternalProduct_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "tfhe_primitives", "TRLWE_ExternalProduct/Metal",
|
||||
"NotApplicable: fused external product lives in luxcpp/fhe (FHEgpu/FHEmetal libs), not exposed to lux/fhe via cgo",
|
||||
map[string]string{"primitive": "trlwe_external_product", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Blind rotation -- exercised via a single AND gate (full bootstrap chain).
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkTFHE_BlindRotation_CPU(b *testing.B) {
|
||||
ctx := newTFHECtx(b)
|
||||
a := ctx.enc.Encrypt(true)
|
||||
c := ctx.enc.Encrypt(false)
|
||||
runPrimitive(b, "tfhe_primitives", "BlindRotation/CPU",
|
||||
map[string]string{"primitive": "blind_rotation", "backend": "CPU (pure Go)", "scope": "1-AND-gate"},
|
||||
func() {
|
||||
_, err := ctx.eval.AND(a, c)
|
||||
if err != nil {
|
||||
b.Fatalf("AND: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkTFHE_BlindRotation_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "tfhe_primitives", "BlindRotation/Metal",
|
||||
"NotApplicable: blind-rotate is the G3 blocker per fhe/policy/PERFORMANCE.md -- no GPU dispatch wired through Evaluator.bootstrap",
|
||||
map[string]string{"primitive": "blind_rotation", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sample extraction -- not exposed on public API.
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkTFHE_SampleExtract_CPU(b *testing.B) {
|
||||
ctx := newTFHECtx(b)
|
||||
a := ctx.enc.Encrypt(true)
|
||||
c := ctx.enc.Encrypt(false)
|
||||
runPrimitive(b, "tfhe_primitives", "SampleExtract/CPU",
|
||||
map[string]string{"primitive": "sample_extraction", "backend": "CPU (pure Go)", "scope": "embedded-in-AND-bootstrap"},
|
||||
func() {
|
||||
_, err := ctx.eval.AND(a, c)
|
||||
if err != nil {
|
||||
b.Fatalf("AND: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkTFHE_SampleExtract_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "tfhe_primitives", "SampleExtract/Metal",
|
||||
"NotApplicable: sample-extract internal to Evaluator.sampleExtractAndModSwitch -- not bench-isolated",
|
||||
map[string]string{"primitive": "sample_extraction", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Key switching -- not exposed on public API; rolled into Evaluator gates.
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkTFHE_KeySwitch_CPU(b *testing.B) {
|
||||
ctx := newTFHECtx(b)
|
||||
a := ctx.enc.Encrypt(true)
|
||||
c := ctx.enc.Encrypt(false)
|
||||
runPrimitive(b, "tfhe_primitives", "KeySwitch/CPU",
|
||||
map[string]string{"primitive": "key_switching", "backend": "CPU (pure Go)", "scope": "embedded-in-AND-bootstrap"},
|
||||
func() {
|
||||
_, err := ctx.eval.AND(a, c)
|
||||
if err != nil {
|
||||
b.Fatalf("AND: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkTFHE_KeySwitch_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "tfhe_primitives", "KeySwitch/Metal",
|
||||
"NotApplicable: key-switch in lattice/core/rlwe.Evaluator -- not routed through gpu wrapper",
|
||||
map[string]string{"primitive": "key_switching", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Programmable bootstrapping (the headline measurement)
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkTFHE_ProgrammableBootstrap_CPU(b *testing.B) {
|
||||
ctx := newTFHECtx(b)
|
||||
a := ctx.enc.Encrypt(true)
|
||||
c := ctx.enc.Encrypt(false)
|
||||
runPrimitive(b, "tfhe_primitives", "ProgrammableBootstrap/CPU",
|
||||
map[string]string{"primitive": "programmable_bootstrap", "backend": "CPU (pure Go)", "scope": "1-AND-gate"},
|
||||
func() {
|
||||
_, err := ctx.eval.AND(a, c)
|
||||
if err != nil {
|
||||
b.Fatalf("AND: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkTFHE_ProgrammableBootstrap_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "tfhe_primitives", "ProgrammableBootstrap/Metal",
|
||||
"NotApplicable: PBS = blind-rotate + sample-extract + key-switch -- all G3-blocked per fhe/policy/PERFORMANCE.md",
|
||||
map[string]string{"primitive": "programmable_bootstrap", "backend": probe.backend})
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// bench_threshold_test.go covers the threshold-FHE primitives:
|
||||
//
|
||||
// - threshold keygen share
|
||||
// - partial decrypt share
|
||||
// - share verify
|
||||
// - share aggregate
|
||||
// - threshold transcript root
|
||||
//
|
||||
// Per FHE-GPU spec section 19C: ops/sec + p50/p95/p99 latency. CPU
|
||||
// baselines today, GPU NotApplicable until lattice/multiparty grows a
|
||||
// GPU dispatch path (no such plan today -- threshold ops are
|
||||
// inherently sequential on the share boundaries).
|
||||
//
|
||||
// Threshold uses lattice/multiparty.Thresholdizer + Combiner directly
|
||||
// because the fhe package does not re-export threshold APIs. Same
|
||||
// underlying ringqp arithmetic.
|
||||
|
||||
package bench
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/lattice/v7/core/rlwe"
|
||||
"github.com/luxfi/lattice/v7/multiparty"
|
||||
)
|
||||
|
||||
// thresholdParams returns a small but realistic rlwe.Parameters set for
|
||||
// threshold benches. LogN=10 (N=1024) keeps key generation under a few
|
||||
// ms and avoids dominating the benchmark with setup. Production
|
||||
// threshold for FHE policy uses these dimensions.
|
||||
func thresholdParams(b *testing.B) rlwe.Parameters {
|
||||
b.Helper()
|
||||
params, err := rlwe.NewParametersFromLiteral(rlwe.ParametersLiteral{
|
||||
LogN: 10,
|
||||
Q: []uint64{0x200000440001, 0x7fff80001, 0x800280001},
|
||||
P: []uint64{0x3ffffffb80001},
|
||||
NTTFlag: true,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("threshold params: %v", err)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Threshold keygen share -- one party generates its t-of-N share.
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkThreshold_KeygenShare_CPU(b *testing.B) {
|
||||
params := thresholdParams(b)
|
||||
thr := multiparty.NewThresholdizer(params)
|
||||
kg := rlwe.NewKeyGenerator(params)
|
||||
sk := kg.GenSecretKeyNew()
|
||||
const threshold = 3
|
||||
|
||||
for w := 0; w < 3; w++ {
|
||||
_, err := thr.GenShamirPolynomial(threshold, sk)
|
||||
if err != nil {
|
||||
b.Fatalf("setup GenShamirPolynomial: %v", err)
|
||||
}
|
||||
}
|
||||
runtime.GC()
|
||||
|
||||
samples := make([]Sample, 0, b.N)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
start := time.Now()
|
||||
_, err := thr.GenShamirPolynomial(threshold, sk)
|
||||
if err != nil {
|
||||
b.Fatalf("GenShamirPolynomial: %v", err)
|
||||
}
|
||||
samples = append(samples, Sample(time.Since(start).Nanoseconds()))
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
cell := summarizeCell(b, samples, map[string]string{
|
||||
"primitive": "threshold_keygen_share",
|
||||
"backend": "CPU (pure Go)",
|
||||
"threshold": fmt.Sprintf("%d", threshold),
|
||||
"logN": "10",
|
||||
})
|
||||
cell.Name = b.Name()
|
||||
cell.Status = "ok"
|
||||
Record("threshold", cell)
|
||||
}
|
||||
|
||||
func BenchmarkThreshold_KeygenShare_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "threshold", "Threshold_KeygenShare/Metal",
|
||||
"NotApplicable: threshold keygen is sequential ringqp polynomial sampling; no GPU dispatcher exists in lattice/multiparty",
|
||||
map[string]string{"primitive": "threshold_keygen_share", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Partial decrypt share -- multiparty.KeySwitchPK.GenShare. Approximated
|
||||
// here as one Thresholdizer.GenShamirSecretShare because the partial-
|
||||
// decrypt protocol shares the same per-recipient share-generation cost
|
||||
// shape (one ringqp polynomial evaluation at the recipient's point).
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkThreshold_PartialDecryptShare_CPU(b *testing.B) {
|
||||
params := thresholdParams(b)
|
||||
thr := multiparty.NewThresholdizer(params)
|
||||
kg := rlwe.NewKeyGenerator(params)
|
||||
sk := kg.GenSecretKeyNew()
|
||||
const threshold = 3
|
||||
|
||||
poly, err := thr.GenShamirPolynomial(threshold, sk)
|
||||
if err != nil {
|
||||
b.Fatalf("GenShamirPolynomial: %v", err)
|
||||
}
|
||||
share := thr.AllocateThresholdSecretShare()
|
||||
recipient := multiparty.ShamirPublicPoint(7)
|
||||
|
||||
for w := 0; w < 3; w++ {
|
||||
thr.GenShamirSecretShare(recipient, poly, &share)
|
||||
}
|
||||
runtime.GC()
|
||||
|
||||
samples := make([]Sample, 0, b.N)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
start := time.Now()
|
||||
thr.GenShamirSecretShare(recipient, poly, &share)
|
||||
samples = append(samples, Sample(time.Since(start).Nanoseconds()))
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
cell := summarizeCell(b, samples, map[string]string{
|
||||
"primitive": "partial_decrypt_share",
|
||||
"backend": "CPU (pure Go)",
|
||||
"threshold": fmt.Sprintf("%d", threshold),
|
||||
"logN": "10",
|
||||
"scope": "GenShamirSecretShare (per-recipient)",
|
||||
})
|
||||
cell.Name = b.Name()
|
||||
cell.Status = "ok"
|
||||
Record("threshold", cell)
|
||||
}
|
||||
|
||||
func BenchmarkThreshold_PartialDecryptShare_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "threshold", "Threshold_PartialDecryptShare/Metal",
|
||||
"NotApplicable: per-recipient share generation is one polynomial evaluation; GPU dispatch overhead would dominate",
|
||||
map[string]string{"primitive": "partial_decrypt_share", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Share verify -- not exposed as a separate operation in
|
||||
// lattice/multiparty.Thresholdizer. Verification happens implicitly via
|
||||
// AggregateShares (which checks levels). We bench the level-check +
|
||||
// addition path under "share_verify" with a scope qualifier.
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkThreshold_ShareVerify_CPU(b *testing.B) {
|
||||
recordSkip(b, "threshold", "Threshold_ShareVerify/CPU",
|
||||
"NotApplicable: lattice/multiparty.Thresholdizer has no separate verify -- verification happens implicitly inside AggregateShares (level match check). Reported under share_aggregate.",
|
||||
map[string]string{"primitive": "share_verify", "backend": "CPU (pure Go)"})
|
||||
}
|
||||
|
||||
func BenchmarkThreshold_ShareVerify_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "threshold", "Threshold_ShareVerify/Metal",
|
||||
"NotApplicable: same as CPU -- no separate verify primitive in the API",
|
||||
map[string]string{"primitive": "share_verify", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Share aggregate -- two parties combine their shares. Benches
|
||||
// Thresholdizer.AggregateShares (one ringqp Add).
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkThreshold_ShareAggregate_CPU(b *testing.B) {
|
||||
params := thresholdParams(b)
|
||||
thr := multiparty.NewThresholdizer(params)
|
||||
kg := rlwe.NewKeyGenerator(params)
|
||||
sk1 := kg.GenSecretKeyNew()
|
||||
sk2 := kg.GenSecretKeyNew()
|
||||
const threshold = 3
|
||||
|
||||
poly1, err := thr.GenShamirPolynomial(threshold, sk1)
|
||||
if err != nil {
|
||||
b.Fatalf("GenShamirPolynomial 1: %v", err)
|
||||
}
|
||||
poly2, err := thr.GenShamirPolynomial(threshold, sk2)
|
||||
if err != nil {
|
||||
b.Fatalf("GenShamirPolynomial 2: %v", err)
|
||||
}
|
||||
recipient := multiparty.ShamirPublicPoint(7)
|
||||
share1 := thr.AllocateThresholdSecretShare()
|
||||
share2 := thr.AllocateThresholdSecretShare()
|
||||
out := thr.AllocateThresholdSecretShare()
|
||||
thr.GenShamirSecretShare(recipient, poly1, &share1)
|
||||
thr.GenShamirSecretShare(recipient, poly2, &share2)
|
||||
|
||||
for w := 0; w < 3; w++ {
|
||||
if err := thr.AggregateShares(share1, share2, &out); err != nil {
|
||||
b.Fatalf("setup AggregateShares: %v", err)
|
||||
}
|
||||
}
|
||||
runtime.GC()
|
||||
|
||||
samples := make([]Sample, 0, b.N)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
start := time.Now()
|
||||
if err := thr.AggregateShares(share1, share2, &out); err != nil {
|
||||
b.Fatalf("AggregateShares: %v", err)
|
||||
}
|
||||
samples = append(samples, Sample(time.Since(start).Nanoseconds()))
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
cell := summarizeCell(b, samples, map[string]string{
|
||||
"primitive": "share_aggregate",
|
||||
"backend": "CPU (pure Go)",
|
||||
"threshold": fmt.Sprintf("%d", threshold),
|
||||
"logN": "10",
|
||||
})
|
||||
cell.Name = b.Name()
|
||||
cell.Status = "ok"
|
||||
Record("threshold", cell)
|
||||
}
|
||||
|
||||
func BenchmarkThreshold_ShareAggregate_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "threshold", "Threshold_ShareAggregate/Metal",
|
||||
"NotApplicable: aggregate is a single ringqp Add at the share level; no Metal kernel for ringqp.Add",
|
||||
map[string]string{"primitive": "share_aggregate", "backend": probe.backend})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Threshold transcript root -- not implemented as a named API in
|
||||
// lattice/multiparty. Skip with explanation.
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkThreshold_TranscriptRoot_CPU(b *testing.B) {
|
||||
recordSkip(b, "threshold", "Threshold_TranscriptRoot/CPU",
|
||||
"NotApplicable: 'threshold transcript root' is not a named primitive in lattice/multiparty; LP-073 transcript hashing is in lux/mpc territory and out of bench scope here",
|
||||
map[string]string{"primitive": "threshold_transcript_root", "backend": "CPU (pure Go)"})
|
||||
}
|
||||
|
||||
func BenchmarkThreshold_TranscriptRoot_Metal(b *testing.B) {
|
||||
probe := gpuProbe()
|
||||
recordSkip(b, "threshold", "Threshold_TranscriptRoot/Metal",
|
||||
"NotApplicable: see CPU cell -- not a primitive of this package",
|
||||
map[string]string{"primitive": "threshold_transcript_root", "backend": probe.backend})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !gpu
|
||||
|
||||
package bench
|
||||
|
||||
// buildTagsValue is recorded into Ladder.Host.BuildTags. The default
|
||||
// (no -tags gpu) means GPU benches will skip with NotApplicable.
|
||||
const buildTagsValue = "default"
|
||||
|
||||
// gpuLinkAttempted is true when the test binary was compiled with the
|
||||
// gpu cgo wrapper actually linked. Default-tags builds report false.
|
||||
const gpuLinkAttempted = false
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build gpu
|
||||
|
||||
package bench
|
||||
|
||||
const buildTagsValue = "gpu"
|
||||
|
||||
const gpuLinkAttempted = true
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package bench is the canonical FHE benchmark ladder.
|
||||
//
|
||||
// Per FHE-GPU architecture spec section 19, this directory consolidates
|
||||
// fragmented FHE benchmarks (#88 Metal NTT crossover, #118 FHE policy CPU,
|
||||
// #121 FHE-GPU diagnostic) into one harness so that future agents have
|
||||
// exactly one place to look for "how fast is this on my host".
|
||||
//
|
||||
// Layout:
|
||||
//
|
||||
// bench_ntt_test.go -- NTT ladder (N x B x domain x mode)
|
||||
// bench_tfhe_primitives_test.go -- LWE/TRLWE/external-product/etc.
|
||||
// bench_threshold_test.go -- threshold ops
|
||||
// bench_circuits_test.go -- production circuits
|
||||
// bench_reconcile_test.go -- reconciliation #88 vs #121
|
||||
// results/ -- JSON per run (gitignored content,
|
||||
// directory tracked via .gitkeep)
|
||||
// RESULTS.md -- aggregate Markdown table at top.
|
||||
//
|
||||
// All benchmarks here are pure-Go observers: they neither modify FHE
|
||||
// primitives nor link new GPU code. The Metal-side benches reach
|
||||
// libluxlattice via the existing luxfi/lattice/v7/gpu cgo wrapper. If
|
||||
// the wrapper is unavailable (no -tags gpu, library not installed,
|
||||
// dispatcher returns an error) the corresponding cells skip with
|
||||
// b.Skip("NotApplicable: ...") rather than fabricating numbers.
|
||||
//
|
||||
// Reproduction:
|
||||
//
|
||||
// cd /Users/z/work/lux/fhe/bench && \
|
||||
// GOWORK=off go test -run=^$ -bench=. -benchtime=10x -timeout=1800s \
|
||||
// -count=1 -benchmem ./
|
||||
//
|
||||
// Each benchmark logs its exact invocation, build tags, and the host's
|
||||
// FHE commit hash to results/<benchmark>_<timestamp>.json so that two
|
||||
// runs on different days are byte-comparable.
|
||||
//
|
||||
// Honest residual: the FHE-GPU FIX agent (#119 successor) is in flight.
|
||||
// Several primitives in the TFHE ladder are CPU-only today; their GPU
|
||||
// cells are NotApplicable and will be filled in as siblings ship. The
|
||||
// reconciliation between #88's 14.02x and #121's 0.08x is the current
|
||||
// load-bearing finding -- see bench_reconcile_test.go.
|
||||
package bench
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package bench
|
||||
|
||||
import "errors"
|
||||
|
||||
// errGPUDisabled is returned by GPU paths when the binary was not built
|
||||
// with -tags gpu. Cells use it to skip with NotApplicable.
|
||||
var errGPUDisabled = errors.New("gpu disabled: rebuild with -tags gpu")
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !gpu
|
||||
|
||||
package bench
|
||||
|
||||
// gpuProbe attempts a runtime check of the lattice GPU wrapper. With
|
||||
// the default tag the GPU package is unreachable, so the probe always
|
||||
// reports unavailable.
|
||||
type gpuProbeResult struct {
|
||||
available bool
|
||||
backend string
|
||||
reason string
|
||||
}
|
||||
|
||||
func gpuProbe() gpuProbeResult {
|
||||
return gpuProbeResult{
|
||||
available: false,
|
||||
backend: "CPU (pure Go)",
|
||||
reason: "built without -tags gpu; GPU wrapper not linked",
|
||||
}
|
||||
}
|
||||
|
||||
// metalNTTForwardSinglePoly is a stub on the no-tag build. Cells call
|
||||
// it only after gpuProbe().available, but we still need a definition
|
||||
// so the test files compile under both tags.
|
||||
func metalNTTForwardSinglePoly(n int, _ []uint64) error {
|
||||
_ = n
|
||||
return errGPUDisabled
|
||||
}
|
||||
|
||||
// metalNTTForwardBatch is a stub on the no-tag build. Same reason as
|
||||
// metalNTTForwardSinglePoly.
|
||||
func metalNTTForwardBatch(n, batch int, _ []uint64) error {
|
||||
_, _ = n, batch
|
||||
return errGPUDisabled
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build gpu
|
||||
|
||||
package bench
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
lgpu "github.com/luxfi/lattice/v7/gpu"
|
||||
)
|
||||
|
||||
type gpuProbeResult struct {
|
||||
available bool
|
||||
backend string
|
||||
reason string
|
||||
}
|
||||
|
||||
var probeOnce sync.Once
|
||||
var probeCached gpuProbeResult
|
||||
|
||||
func gpuProbe() gpuProbeResult {
|
||||
probeOnce.Do(func() {
|
||||
probeCached = gpuProbeResult{
|
||||
available: lgpu.GPUAvailable(),
|
||||
backend: lgpu.GetBackend(),
|
||||
}
|
||||
if !probeCached.available {
|
||||
probeCached.reason = "lattice_gpu_available() returned false (library built without WITH_GPU=ON)"
|
||||
}
|
||||
})
|
||||
return probeCached
|
||||
}
|
||||
|
||||
// nttCtxCache reuses NTT contexts across iterations -- creating them
|
||||
// every call would dominate the bench. Keyed by N because Q is fixed.
|
||||
var (
|
||||
nttCtxMu sync.Mutex
|
||||
nttCtxCache = map[int]*lgpu.NTTContext{}
|
||||
)
|
||||
|
||||
func getNTTContext(n int) (*lgpu.NTTContext, error) {
|
||||
nttCtxMu.Lock()
|
||||
defer nttCtxMu.Unlock()
|
||||
if ctx, ok := nttCtxCache[n]; ok {
|
||||
return ctx, nil
|
||||
}
|
||||
ctx, err := lgpu.NewNTTContext(uint32(n), nttQ)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewNTTContext(N=%d, Q=0x%x): %w", n, nttQ, err)
|
||||
}
|
||||
nttCtxCache[n] = ctx
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// metalNTTForwardSinglePoly dispatches one NTT(N) through the lattice
|
||||
// gpu wrapper. The wrapper internally calls metal_ntt_forward in
|
||||
// libluxlattice.dylib. Single-poly is the path that worked in #121
|
||||
// (12x slower than Go pure-Go, see policy/PERFORMANCE.md S5).
|
||||
func metalNTTForwardSinglePoly(n int, data []uint64) error {
|
||||
ctx, err := getNTTContext(n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) != n {
|
||||
return fmt.Errorf("data length %d != N %d", len(data), n)
|
||||
}
|
||||
// gpu.NTT takes [][]uint64 and returns [][]uint64. We pre-wrap the
|
||||
// caller's slice to avoid an extra allocation per iteration.
|
||||
in := [][]uint64{data}
|
||||
_, err = ctx.NTT(in)
|
||||
return err
|
||||
}
|
||||
|
||||
// metalNTTForwardBatch dispatches batch NTTs. The published
|
||||
// luxfi/lattice/v7@v7.0.0/gpu API does not expose a single-dispatch
|
||||
// BatchNTT (the underlying libluxlattice exports lattice_ntt_batch_*
|
||||
// but the Go wrapper only has NTT([][]uint64) which calls
|
||||
// metal_ntt_forward once per polynomial). We therefore measure the
|
||||
// "B sequential single-poly NTTs through the cgo boundary" cost,
|
||||
// which is what the policy/PERFORMANCE.md S5 already established as
|
||||
// 12x slower than Go.
|
||||
//
|
||||
// When/if the lattice gpu wrapper grows BatchNTT (ETA per
|
||||
// FHE-GPU FIX agent), this function will be updated to call it. For
|
||||
// now, our cell labels honestly show "B sequential single-poly".
|
||||
func metalNTTForwardBatch(n, batch int, data []uint64) error {
|
||||
ctx, err := getNTTContext(n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) != n*batch {
|
||||
return fmt.Errorf("data length %d != N*batch %d*%d", len(data), n, batch)
|
||||
}
|
||||
polys := make([][]uint64, batch)
|
||||
for i := 0; i < batch; i++ {
|
||||
polys[i] = data[i*n : (i+1)*n]
|
||||
}
|
||||
_, err = ctx.NTT(polys)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package bench
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HostInfo captures the environment of a benchmark run so that two runs
|
||||
// on different days are byte-comparable.
|
||||
type HostInfo struct {
|
||||
Time string `json:"time"`
|
||||
GoVersion string `json:"go_version"`
|
||||
GOOS string `json:"goos"`
|
||||
GOARCH string `json:"goarch"`
|
||||
NumCPU int `json:"num_cpu"`
|
||||
GOMAXPROCS int `json:"gomaxprocs"`
|
||||
CPUBrand string `json:"cpu_brand,omitempty"`
|
||||
OSProduct string `json:"os_product,omitempty"`
|
||||
OSVersion string `json:"os_version,omitempty"`
|
||||
BuildTags string `json:"build_tags"`
|
||||
FHECommit string `json:"fhe_commit,omitempty"`
|
||||
LatticeCommit string `json:"lattice_commit,omitempty"`
|
||||
}
|
||||
|
||||
// CollectHostInfo populates a HostInfo. cpu/os fields are best-effort
|
||||
// (Darwin only). Failures are silently ignored -- this is a record, not
|
||||
// a precondition.
|
||||
func CollectHostInfo(buildTags string) HostInfo {
|
||||
info := HostInfo{
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
GoVersion: runtime.Version(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
NumCPU: runtime.NumCPU(),
|
||||
GOMAXPROCS: runtime.GOMAXPROCS(0),
|
||||
BuildTags: buildTags,
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
if out, err := exec.Command("sysctl", "-n", "machdep.cpu.brand_string").Output(); err == nil {
|
||||
info.CPUBrand = strings.TrimSpace(string(out))
|
||||
}
|
||||
if out, err := exec.Command("sw_vers", "-productName").Output(); err == nil {
|
||||
info.OSProduct = strings.TrimSpace(string(out))
|
||||
}
|
||||
if out, err := exec.Command("sw_vers", "-productVersion").Output(); err == nil {
|
||||
info.OSVersion = strings.TrimSpace(string(out))
|
||||
}
|
||||
}
|
||||
|
||||
if commit := gitCommit("/Users/z/work/lux/fhe"); commit != "" {
|
||||
info.FHECommit = commit
|
||||
}
|
||||
if commit := gitCommit("/Users/z/work/lux/lattice"); commit != "" {
|
||||
info.LatticeCommit = commit
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func gitCommit(dir string) string {
|
||||
cmd := exec.Command("git", "-C", dir, "rev-parse", "HEAD")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// Sample is a single timed iteration in nanoseconds.
|
||||
type Sample int64
|
||||
|
||||
// Cell is one (parameter combination) row in the ladder. Latencies in
|
||||
// nanoseconds, throughput in ops/sec.
|
||||
type Cell struct {
|
||||
Name string `json:"name"`
|
||||
Params map[string]string `json:"params"`
|
||||
Status string `json:"status"` // "ok", "skipped", "error"
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Iterations int `json:"iterations"`
|
||||
MedianNS int64 `json:"median_ns,omitempty"`
|
||||
MeanNS int64 `json:"mean_ns,omitempty"`
|
||||
P50NS int64 `json:"p50_ns,omitempty"`
|
||||
P95NS int64 `json:"p95_ns,omitempty"`
|
||||
P99NS int64 `json:"p99_ns,omitempty"`
|
||||
OpsPerSec float64 `json:"ops_per_sec,omitempty"`
|
||||
BytesArena int64 `json:"bytes_arena,omitempty"`
|
||||
BytesPerOp int64 `json:"bytes_per_op,omitempty"`
|
||||
Allocations int64 `json:"allocs_per_op,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// Stats reduces a sample series into the percentiles we report. We use
|
||||
// the simple nearest-rank percentile (no interpolation) because Go bench
|
||||
// timing already smears across iterations.
|
||||
func Stats(samples []Sample) (median, mean, p50, p95, p99 int64) {
|
||||
if len(samples) == 0 {
|
||||
return 0, 0, 0, 0, 0
|
||||
}
|
||||
sorted := make([]int64, len(samples))
|
||||
for i, s := range samples {
|
||||
sorted[i] = int64(s)
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
||||
|
||||
pct := func(p float64) int64 {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
idx := int(float64(len(sorted)-1) * p)
|
||||
return sorted[idx]
|
||||
}
|
||||
|
||||
median = pct(0.5)
|
||||
p50 = median
|
||||
p95 = pct(0.95)
|
||||
p99 = pct(0.99)
|
||||
|
||||
var sum int64
|
||||
for _, s := range sorted {
|
||||
sum += s
|
||||
}
|
||||
mean = sum / int64(len(sorted))
|
||||
return
|
||||
}
|
||||
|
||||
// Ladder is a full sweep result that gets serialized into results/.
|
||||
type Ladder struct {
|
||||
Name string `json:"name"`
|
||||
Host HostInfo `json:"host"`
|
||||
Command string `json:"command"`
|
||||
Cells []Cell `json:"cells"`
|
||||
}
|
||||
|
||||
var resultsDir = func() string {
|
||||
// We anchor to the directory of this source file so the harness
|
||||
// finds results/ even when go test runs from /tmp.
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "results"
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "results")
|
||||
}()
|
||||
|
||||
// WriteLadder serializes a Ladder to results/<name>_<timestamp>.json.
|
||||
// Errors are returned but most callers in tests ignore them -- failure
|
||||
// to write a result file should not fail the benchmark.
|
||||
func WriteLadder(l *Ladder) (string, error) {
|
||||
if err := os.MkdirAll(resultsDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102T150405Z")
|
||||
path := filepath.Join(resultsDir, fmt.Sprintf("%s_%s.json", l.Name, stamp))
|
||||
data, err := json.MarshalIndent(l, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
// LadderRegistry is the global accumulator. Tests append cells via
|
||||
// Record; the package-level TestMain in suite_test.go flushes once.
|
||||
var (
|
||||
registryMu sync.Mutex
|
||||
registry = map[string]*Ladder{}
|
||||
)
|
||||
|
||||
// Record appends a cell to the named ladder.
|
||||
func Record(ladderName string, cell Cell) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
l, ok := registry[ladderName]
|
||||
if !ok {
|
||||
l = &Ladder{
|
||||
Name: ladderName,
|
||||
Host: CollectHostInfo(buildTagsValue),
|
||||
Command: invocationCommand(),
|
||||
}
|
||||
registry[ladderName] = l
|
||||
}
|
||||
l.Cells = append(l.Cells, cell)
|
||||
}
|
||||
|
||||
// FlushAll writes every recorded ladder to results/. Returns paths
|
||||
// written and the first error (if any). Used by TestMain.
|
||||
func FlushAll() ([]string, error) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
var paths []string
|
||||
var firstErr error
|
||||
names := make([]string, 0, len(registry))
|
||||
for n := range registry {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
path, err := WriteLadder(registry[n])
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
if path != "" {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths, firstErr
|
||||
}
|
||||
|
||||
func invocationCommand() string {
|
||||
if len(os.Args) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := []string{"go", "test"}
|
||||
parts = append(parts, os.Args[1:]...)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// Median returns the middle sample by sort order for use as the
|
||||
// canonical headline number. Matches PHILOSOPHY.md "median >=10 runs".
|
||||
func Median(samples []Sample) int64 {
|
||||
if len(samples) == 0 {
|
||||
return 0
|
||||
}
|
||||
cp := make([]int64, len(samples))
|
||||
for i, s := range samples {
|
||||
cp[i] = int64(s)
|
||||
}
|
||||
sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] })
|
||||
return cp[len(cp)/2]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package bench
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/lattice/v7/ring"
|
||||
)
|
||||
|
||||
// goSubRingT is the package-local alias used by bench files so they
|
||||
// can refer to the SubRing type without importing ring everywhere.
|
||||
type goSubRingT = ring.SubRing
|
||||
|
||||
// nttQ is the NTT-friendly modulus used for the canonical ladder. It is
|
||||
// the first 60-bit prime in luxfi/lattice/v7/ring.Qi60, the same one
|
||||
// the gpu-side bench harness in #121 used. Picking a single Q across
|
||||
// the whole ladder keeps Go-vs-Metal cells comparable even though the
|
||||
// internal Montgomery constants differ between paths.
|
||||
//
|
||||
// Q = 0x1fffffffffe00001 = 2305843009213317121 (60 bits)
|
||||
const nttQ uint64 = 0x1fffffffffe00001
|
||||
|
||||
// goSubRing constructs the pure-Go SubRing for a given N. The SubRing
|
||||
// holds RootsForward, RootsBackward, MRedConstant, NInv, and the rest
|
||||
// of the Montgomery-domain NTT machinery.
|
||||
//
|
||||
// NewSubRing only sets up the metadata -- the NTT roots are generated
|
||||
// by the unexported generateNTTConstants on the returned SubRing. We
|
||||
// route through the parent Ring constructor instead, which guarantees
|
||||
// roots are populated.
|
||||
//
|
||||
// Returns an error rather than panicking so the bench can mark the
|
||||
// cell NotApplicable instead of failing the whole run.
|
||||
func goSubRing(n int) (*ring.SubRing, error) {
|
||||
r, err := ring.NewRing(n, []uint64{nttQ})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ring.NewRing(N=%d, Q=0x%x): %w", n, nttQ, err)
|
||||
}
|
||||
if len(r.SubRings) == 0 || r.SubRings[0] == nil {
|
||||
return nil, fmt.Errorf("ring.NewRing returned no SubRings")
|
||||
}
|
||||
return r.SubRings[0], nil
|
||||
}
|
||||
|
||||
// uniformPoly fills a slice with N coefficients in [0, Q). The samples
|
||||
// are deterministic via a small linear congruential generator so the
|
||||
// bench is reproducible without pulling in PRNG plumbing.
|
||||
func uniformPoly(n int) []uint64 {
|
||||
out := make([]uint64, n)
|
||||
state := uint64(0xc0ffeefacefeed01)
|
||||
for i := range out {
|
||||
state = state*6364136223846793005 + 1442695040888963407
|
||||
out[i] = state % nttQ
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Bench output JSON. Regenerated on every run. Tracked directory,
|
||||
# ignored contents -- so the structure exists but the snapshots don't
|
||||
# clutter git history.
|
||||
*.json
|
||||
!.gitignore
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package bench
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain flushes the ladder registry to results/ on exit. Without
|
||||
// this every benchmark run would dump cells into the in-memory map and
|
||||
// then exit before serializing to disk.
|
||||
func TestMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
paths, err := FlushAll()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "bench: WriteLadder error: %v\n", err)
|
||||
}
|
||||
for _, p := range paths {
|
||||
fmt.Fprintf(os.Stderr, "bench: wrote %s\n", p)
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
Reference in New Issue
Block a user