Bench harness was calling NTTContext.NTT([][]uint64) which iterates B sequential single-poly cgo dispatches -- the wrong path for measuring Metal batch performance. Updated to MontgomeryNTTContext.Forward(data, batch) which is a true single-call batched dispatch (calls lattice_ntt_forward with batch>=1 in one cgo crossing). Combined with the lattice_gpu_available() fix in luxcpp/lattice and the Montgomery byte-equal port (lattice 0507c925) + BatchNTT C ABI fix (luxcpp 3fd1ed7), this populates the full N x B Metal table. Honest measurements (M1 Max, 10 iter median, kernel-only): - N=4096 B=1: 0.06x (Metal slower; ~470 us command-queue floor) - N=4096 B=32: 2.30x (crossover) - N=4096 B=128: 2.03x (the published 14x config -- did not reproduce) - N=4096 B=2048: 16.71x peak Crossover B per N: 8 (small N), 32 (N=4096), 8 (large N). Single-poly Metal NTT is strictly slower than Go on M1 Max -- matching the nttThreshold=0 default in lattice/gpu. Closes #132. G3a (4.61x CPU goroutine) shipped separately in lattice d11ec53c. G3b (Metal batch-bootstrap kernel) deferred -- the byte-equal Montgomery kernel is in place; remaining work is Lattigo-side blindrot refactor to dispatch inner NTTs at B>=64.
18 KiB
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: -tags=gpu, PKG_CONFIG_PATH=/Users/z/work/luxcpp/install/lib/pkgconfig.
Date: 2026-04-27 (post FHE-GPU FIX 0507c925 + 3fd1ed7 + #133 G3a).
FHE commit: d6affbfd6dadf28aa100dcbf73c75caf61dcc539.
Lattice commit: d11ec53c16e7ef6d0d1fc84ea37fd285e1f1f9d2
(includes Montgomery NTT byte-equal Lattigo + BatchEvaluate API).
luxlattice dylib: rebuilt 2026-04-27 with Metal NTT probe wired
through metal_ntt_available() (was returning false from legacy
MLX stub before this fix).
Reproduce:
cd /Users/z/work/lux/fhe/bench && \
GOWORK=off PKG_CONFIG_PATH=/Users/z/work/luxcpp/install/lib/pkgconfig \
DYLD_LIBRARY_PATH=/Users/z/work/luxcpp/install/lib \
go test -run=^$ -bench=. -benchtime=10x -timeout=1800s \
-count=1 -benchmem -tags=gpu ./
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. Median of 10 runs.
| N | B=1 | B=8 | B=32 | B=128 | B=512 | B=2048 |
|---|---|---|---|---|---|---|
| 1024 | 133.0 us (133.00/NTT) | 1.58 ms (196.97/NTT) | 1.70 ms (53.25/NTT) | 5.98 ms (46.75/NTT) | 28.22 ms (55.12/NTT) | 105.90 ms (51.71/NTT) |
| 2048 | 74.8 us (74.79/NTT) | 619.8 us (77.47/NTT) | 2.64 ms (82.64/NTT) | 8.44 ms (65.96/NTT) | 31.16 ms (60.85/NTT) | 145.23 ms (70.91/NTT) |
| 4096 | 104.2 us (104.25/NTT) | 511.6 us (63.95/NTT) | 2.22 ms (69.24/NTT) | 10.29 ms (80.39/NTT) | 39.61 ms (77.37/NTT) | 162.62 ms (79.40/NTT) |
| 8192 | 112.6 us (112.58/NTT) | 831.7 us (103.96/NTT) | 3.41 ms (106.63/NTT) | 14.71 ms (114.89/NTT) | 57.50 ms (112.30/NTT) | 246.99 ms (120.60/NTT) |
| 16384 | 163.0 us (162.96/NTT) | 1.42 ms (177.24/NTT) | 6.01 ms (187.87/NTT) | 26.32 ms (205.59/NTT) | 105.85 ms (206.74/NTT) | 417.83 ms (204.02/NTT) |
Per-NTT cost is essentially flat across B at fixed N, except for B=1
which carries the per-iteration setup overhead that the harness pays
on the outer b.N loop. 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.
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 within 5% of
Montgomery.
Mode comparison
kernel-only, warm-buffer, and copy-included agree to within
3-10%. 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, Montgomery byte-equal)
Cells now populated. The bench harness has been rewired to use
MontgomeryNTTContext.Forward(data, batch) -- the byte-equal Lattigo
dispatch path landed in lattice commit 0507c925 and rebuilt for
this run. This is a true single-dispatch batch (calls
lattice_ntt_forward(ctx, data, batch)), not B sequential calls.
Q = 0x1fffffffffe00001, Mode = kernel-only, domain = Montgomery.
Source: bench_ntt_test.go + gpu_probe_gpu.go. Median of 10 runs.
| N | B=1 | B=8 | B=32 | B=128 | B=512 | B=2048 |
|---|---|---|---|---|---|---|
| 1024 | 467.2 us (467.25/NTT) | 509.6 us (63.70/NTT) | 511.2 us (15.98/NTT) | 634.6 us (4.96/NTT) | 1.23 ms (2.41/NTT) | 3.30 ms (1.61/NTT) |
| 2048 | 470.5 us (470.54/NTT) | 546.4 us (68.30/NTT) | 598.8 us (18.71/NTT) | 2.20 ms (17.15/NTT) | 2.37 ms (4.63/NTT) | 6.22 ms (3.04/NTT) |
| 4096 | 1.89 ms (1893.96/NTT) | 1.28 ms (160.31/NTT) | 965.3 us (30.17/NTT) | 5.07 ms (39.65/NTT) | 4.79 ms (9.35/NTT) | 9.73 ms (4.75/NTT) |
| 8192 | 542.2 us (542.25/NTT) | 728.3 us (91.04/NTT) | 1.09 ms (34.19/NTT) | 2.33 ms (18.21/NTT) | 6.98 ms (13.63/NTT) | 17.62 ms (8.60/NTT) |
| 16384 | 618.0 us (617.96/NTT) | 838.0 us (104.75/NTT) | 1.61 ms (50.25/NTT) | 4.21 ms (32.85/NTT) | 9.99 ms (19.52/NTT) | 34.90 ms (17.04/NTT) |
Speedup ratio (Go / Metal, ratio > 1 means Metal faster)
| N | B=1 | B=8 | B=32 | B=128 | B=512 | B=2048 |
|---|---|---|---|---|---|---|
| 1024 | 0.28x | 3.09x | 3.33x | 9.43x | 22.87x | 32.12x |
| 2048 | 0.16x | 1.13x | 4.42x | 3.85x | 13.15x | 23.35x |
| 4096 | 0.06x | 0.40x | 2.30x | 2.03x | 8.27x | 16.71x |
| 8192 | 0.21x | 1.14x | 3.12x | 6.31x | 8.24x | 14.02x |
| 16384 | 0.26x | 1.69x | 3.74x | 6.26x | 10.59x | 11.97x |
Crossover thresholds (per N, smallest B where Metal beats Go)
| N | Crossover B | Speedup at crossover | Speedup at peak (B=2048) |
|---|---|---|---|
| 1024 | B>=8 | 3.09x | 32.12x |
| 2048 | B>=8 | 1.13x | 23.35x |
| 4096 | B>=32 | 2.30x | 16.71x |
| 8192 | B>=8 | 1.14x | 14.02x |
| 16384 | B>=8 | 1.69x | 11.97x |
Single-poly Metal NTT is strictly slower than Go on M1 Max at every
N. Per-call command-queue overhead floor is ~470 us (independent of N
for N<=2048). This is why gpu.SetNTTThreshold(0) is the default --
the SubRing.NTT auto-dispatch stays disabled because the typical
caller (blindrot single-poly chain) would pay the per-call overhead
without batching benefit. G3 (batch-bootstrap kernel) is what
unlocks the multi-x speedup demonstrated in this table.
N=4096 B=128 (canonical "14x" config from #88)
| Path | Time | Per-NTT | Vs Go |
|---|---|---|---|
| Go pure-Go | 10.29 ms | 80.39 us | 1.00x |
| Metal Montgomery batched | 5.07 ms | 39.65 us | 2.03x |
The 14x claim does not reproduce on this M1 Max with the
luxlattice Metal kernel -- 2x is the honest reading. The 14x
reference (luxcpp/crypto/CROSSOVER.md row 4) was measured against the
F-Chain MLX path (luxcpp/fhe/src/core/lib/math/hal/mlx/) which uses
Apple's MLX library and routes through the AMX matrix coprocessor for
multiply-heavy ops. The direct Metal compute path used here exercises
only the GPU's SIMD ALU. Both numbers are correct for what they
measured; only one is reachable from luxfi/fhe Go code today.
N=4096 B=2048 (peak batched config)
| Path | Time | Per-NTT | Vs Go |
|---|---|---|---|
| Go pure-Go | 162.62 ms | 79.40 us | 1.00x |
| Metal Montgomery batched | 9.73 ms | 4.75 us | 16.71x |
This is the real Metal advantage: at large B, Metal sustained throughput dominates. The dispatch cost is amortised across 2048 polys; per-NTT cost drops to ~5 us, an order of magnitude under Go. Production blind-rotate chains do not currently dispatch at this batch size -- closing that gap is G3.
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 (us) | 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 us | NotApplicable | Go via SubRing.Add; gpu wrapper has CPU-side PolyAdd only |
| TRLWE external product (N=2048) | 28.4 us | NotApplicable | NTT + pointwise + INTT; fused MLX kernel lives in luxcpp/fhe (not exposed) |
| Blind rotation (1 AND gate) | 95.1 ms | NotApplicable (deferred) | full bootstrap chain; G3 still CPU-only |
| 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 (deferred) | 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. G3a (CPU goroutine
batch-bootstrap) shipped 4.61x speedup across the bootstrap chain
(see lattice commit d11ec53c), taking per-policy time toward
~1.2 s on M1 Max via parallel cores. G3b (Metal batch-bootstrap
kernel) remains deferred: it requires a Lattigo-side rewrite to
issue batched NTT dispatches (B>=64 to cross over per the table
above) instead of serial per-poly NTTs. The byte-equal Montgomery
kernel is now in place, so this is a Lattigo-side rewrite, not a
kernel port.
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 (us) | 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. With G3a (Bootstrap.BatchEvaluate, 4.61x CPU goroutine speedup) the parallel ceiling is ~21 ms per bootstrap on 8 P-cores -- order eligibility drops from 4.4 s to ~960 ms when callers use the new batched API.
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 |
post-FHE-GPU FIX: 2.03x at N=4096 B=128, 16.71x at N=4096 B=2048; #121 measured the pre-fix path (single-poly only, 12x slower than Go) |
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 | no NTT code; falsifies the section-19E hypothesis |
Reconciliation answer
#88's 14.02x and #121's 0.08x measured two different Metal NTT implementations, and the post-FHE-GPU FIX number is a third (this ladder). Specifically:
-
#121 (0.08x) measured
luxcpp/lattice/src/metal/metal_ntt.mmpre-Montgomery-port. Single-poly only (BatchNTT SIGSEGV'd). The C ABI signature mismatch (Go declared flat buffer, C declared array-of-pointers) caused the segfault; fixed in 0507c925. -
#88 (14.02x) measured
luxcpp/fhe/src/core/lib/math/hal/mlx/, the F-Chain fused dispatcher. Not reachable from Go. The fused kernel runs all log(N) stages in a single kernel launch with shared-memory butterflies and uses MLX's AMX coprocessor routing. -
Post-FHE-GPU FIX (this ladder, 2.03x at B=128, 16.71x at B=2048) measures the same
luxcpp/lattice/src/metal/metal_ntt.mmpath that #121 measured, but with: (1) Montgomery byte-equal port, (2) BatchNTT C ABI fix, (3) batched dispatch throughMontgomeryNTTContext.Forward(data, batch). The peak speedup exceeds #88's 14x at B=2048 because amortisation dominates -- but per-NTT cost (4.75 us at peak) is still slower than #88's N=4096 B=128 fused path (~12 us implied from CROSSOVER.md, but on a different kernel).
Reconciliation cells (this run)
benchtime=10x, -tags=gpu. From
results/reconcile_88_121_20260427T221527Z.json:
| Path | N | B | Median | Per-NTT | Verdict |
|---|---|---|---|---|---|
| Go pure-Go single-poly | 4096 | 1 | 148.0 us | 148.0 us | baseline (note: includes setup; harness Go ladder kernel-only is 104 us) |
| Go pure-Go batched-128 (sequential) | 4096 | 128 | 13.04 ms | 101.9 us | per-NTT close to single-poly -- batch is free in Go |
| Metal luxlattice single-poly | 4096 | 1 | 586.5 us | 586.5 us | 3.96x slower than Go (vs #121's 12x slower -- improvement from Montgomery port) |
| Metal luxlattice batched-128 fused | 4096 | 128 | 1.30 ms | 10.20 us | 9.99x faster than Go batched-128 -- new datapoint, post FHE-GPU FIX |
The Metal cells now reproduce the order-of-magnitude speedup the #88 claim implied; the exact 14x ratio is kernel-design-dependent and this lattice/Metal kernel hits ~10x at B=128, peaking at ~17x at B=2048.
Outstanding remediation (sibling-agent territory)
The fix is not required to "make luxlattice's Metal NTT 14x faster". The 17x peak already exceeds 14x. Open work:
-
G3b (deferred): Move the fused-kernel design into batch bootstrap. Lifting
metal_dispatch_optimized.h'sNTTMetalDispatcherOptimized(single-launch log(N) stages with shared-memory butterflies) intoluxcpp/lattice/src/metal/would recover the per-NTT advantage at lower B. Cost estimate: ~1 week. The current kernel is competitive at B>=128 but the F-Chain path wins at B=64, which matters for blind-rotate. -
G3 (deferred, dominant work): Batched blind-rotate chain. The bootstrap chain serialises 512 gadget products before each NTT, so the 16x peak amortises poorly. Refactoring blindrot to issue batched NTT dispatches (B>=64 to cross over per the table above) instead of serial per-poly NTTs is a Lattigo-side rewrite. The byte-equal Montgomery kernel is in place, so this is a rewrite, not a kernel port. Effort: ~4 weeks.
Honest residual
-
Single-poly Metal NTT is slower than Go at every N. Floor is ~470 us per call (Metal command-queue overhead). Crossover B ranges from 8 (small N) to 32 (N=4096). Below the crossover, the caller must batch -- there is no auto-dispatch path that wins.
-
G3b (Metal batch-bootstrap kernel) deferred. With G3a (CPU goroutine, 4.61x) shipped, the focus shifts to: (a) wiring Bootstrap.BatchEvaluate through policy hot-path callers; (b) refactoring blindrot to dispatch the inner NTTs at B>=64. Either lands within the week; a Metal kernel port can wait.
-
PN11QP54 not benched. Same reason as
policy/PERFORMANCE.md-- production parameter set differs from test default. Follow-on after the Bootstrap.BatchEvaluate wiring 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.gosweep is 8+ minutes. We ran the boolean gate (95 ms) explicitly to validate the harness and used per-bootstrap multiplication for the longer estimates. With G3a available, the next bench cycle should re-measure these circuits usingBootstrap.BatchEvaluatecallers. -
lattice_gpu_available() needed a patch. Pre-fix the C ABI delegated to legacy
mlx_ntt_gpu_available()which returned false unless the library was built withWITH_GPU=ON. The Metal NTT path inmetal_ntt.mmis independently available on Apple hardware without WITH_GPU. Patchedluxcpp/lattice/src/lattice.cpp::lattice_gpu_available()to also checkmetal_ntt_available()whenHAVE_METAL_NTTis defined. Library rebuilt 2026-04-27 12:54.
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 |
92 | Probe via lattice/v7/gpu (cgo); MontgomeryNTTContext dispatch |
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 | 2106 | (excluding RESULTS.md and results/*.json) |
results/ contains JSON output per ladder, named
<ladder>_<UTC-timestamp>.json.