mirror of
https://github.com/luxfi/pulsar.git
synced 2026-09-24 00:25:52 +00:00
bench(pulsar): HYPERBALL no-reconstruct sign + reconstruct-tax baseline
Decomplected, one concern: SignHyperball (no-reconstruct 3-round threshold
signer) cost per (mode in {65,87}, family). Keygen is setup; the timed op
is the full sign. Reports derived K parallel slots, per-party subset load,
sig bytes, and the mean rejection-round count (rounds/op).
Real finding (Quasar finality deployment): the no-reconstruct path runs
K=126-256 parallel commitment slots -> 250-580 ms/op and 1.8-4.6M allocs/op,
a 120-290x tax over the reconstruct path (BenchmarkHyperballReconstructTax,
1.85-2.4 ms). n=16,t=14 EXHAUSTS the 64-round budget on the hyperball path
(reported as a skip) though the reconstruct path signs it in ~8 ms -- the
real no-reconstruct scaling boundary.
Per-round cost is not independently invocable (one-shot signer loops the 3
rounds x K slots internally); reported as derived rounds/op, flagged in the
file header as a public-surface observation (not refactored).
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// bench_hyperball_sign_test.go — DECOMPLECTED benchmark, ONE concern: the cost
|
||||
// of the Mithril HYPERBALL no-reconstruct threshold signer (SignHyperball,
|
||||
// mithril_rss_hyperball.go) — the path where NO party and NO coordinator ever
|
||||
// reconstructs the full (s1,s2); each active party emits only z_j = y_j + c·s1_(j)
|
||||
// and the hint is recovered from the PUBLIC w' = A·z − c·t1·2^d.
|
||||
//
|
||||
// What this isolates and why it matters: SignHyperball runs K parallel
|
||||
// commitment slots (kReps, derived per committee by deriveHyperballParams) over
|
||||
// T parties, re-running up to maxRounds until one slot clears the boundary, the
|
||||
// ‖z‖∞ bound, the FIPS-204 hint, and all-party acceptance. The per-sign COMPUTE
|
||||
// is ∝ kReps·T (network latency = 3 protocol rounds × RTT is additive on top in
|
||||
// a real deployment; this in-process number is the pure compute). The committee
|
||||
// therefore drives block-time feasibility. The benchmark keeps keygen in setup
|
||||
// (not timed) and measures only the sign, reporting the derived K (parallel
|
||||
// slots), the per-party subset load, the sig size, and — the rejection-sampling
|
||||
// cost the task asks for — the MEAN winning-round count across the timed
|
||||
// iterations (rounds/op), i.e. how many maxRounds re-runs the signer consumed.
|
||||
//
|
||||
// Per-round cost is NOT independently invocable: SignHyperball loops the 3
|
||||
// protocol rounds × K slots internally behind a single call (by design — it is
|
||||
// a one-shot signer, not a steppable state machine), so the per-round cost is
|
||||
// the derived total/rounds, reported via the rounds/op metric rather than as a
|
||||
// separate sub-benchmark. This is an observation about the public surface, not
|
||||
// a defect — the round messages are exercised structurally by the no-leak gate.
|
||||
//
|
||||
// Scope: ML-DSA-65/87 only (deriveHyperballParams fail-closes ML-DSA-44 with
|
||||
// ErrHyperballScope — ML-DSA-44 violates the BCC ‖c·t0‖∞ < γ2 bound). Families:
|
||||
// the admitted RSS committees n=8,t=8 / n=8,t=7 / n=16,t=14; n=16,t=12 skips in
|
||||
// keygen (ValidateCommittee rejects it).
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// cd pulsar && export SDKROOT="$(xcrun --show-sdk-path)"; export GOWORK=off
|
||||
// go test -run='^$' -bench=BenchmarkHyperball -benchmem -benchtime=10x ./ref/go/pkg/pulsar/
|
||||
package pulsar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// hyperballSignModes — the BCC-proven scope (deriveHyperballParams admits 65/87).
|
||||
var hyperballSignModes = []Mode{ModeP65, ModeP87}
|
||||
|
||||
// hyperballSignFamilies — the admitted RSS committees plus the rejected
|
||||
// n=16,t=12 (skips in keygen, documenting the wall on the signing side too).
|
||||
var hyperballSignFamilies = []committeeFamily{
|
||||
{n: 8, t: 8},
|
||||
{n: 8, t: 7},
|
||||
{n: 16, t: 14},
|
||||
{n: 16, t: 12},
|
||||
}
|
||||
|
||||
// hyperballSignBudget is the maxRounds re-run budget — the same 64 the
|
||||
// no-reconstruct gate tests use. (n=16,t=14 exhausts this within budget on the
|
||||
// hyperball path — a real scaling boundary, reported as a skip — even though the
|
||||
// reconstruct path signs it fine.)
|
||||
const hyperballSignBudget = 64
|
||||
|
||||
// reconstructBudget is the BCC rejection-sampling attempt ceiling for the
|
||||
// reconstruct-path baseline. It is the per-nonce retry budget (a different
|
||||
// mechanism than the hyperball protocol rounds), set generously so the ~9%
|
||||
// boundary yield reliably clears — at budget 64 a single committee/seed
|
||||
// exhausts spuriously ~0.2% of the time, polluting the mean-cost number.
|
||||
const reconstructBudget = 256
|
||||
|
||||
type hyperballSignCase struct {
|
||||
mode Mode
|
||||
t, n int
|
||||
}
|
||||
|
||||
func hyperballSignCases() []hyperballSignCase {
|
||||
out := make([]hyperballSignCase, 0, len(hyperballSignModes)*len(hyperballSignFamilies))
|
||||
for _, mode := range hyperballSignModes {
|
||||
for _, fam := range hyperballSignFamilies {
|
||||
out = append(out, hyperballSignCase{mode: mode, t: fam.t, n: fam.n})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BenchmarkHyperballNoReconstructSign measures SignHyperball per (mode, family).
|
||||
// Keygen + a warm viability sign are setup (not timed). The timed op is the full
|
||||
// no-reconstruct sign; rounds/op reports the mean rejection-round count.
|
||||
func BenchmarkHyperballNoReconstructSign(b *testing.B) {
|
||||
msg := []byte("Quasar finality subject M — hyperball no-reconstruct sign bench")
|
||||
ctx := []byte("quasar-pulsar-leg")
|
||||
|
||||
for _, tc := range hyperballSignCases() {
|
||||
mode, t, n := tc.mode, tc.t, tc.n
|
||||
b.Run(fmt.Sprintf("%s_N%d_T%d", mode, n, t), func(b *testing.B) {
|
||||
mk, err := MithrilRSSKeygen(mode, t, n, mithrilSeeds(n))
|
||||
if err != nil {
|
||||
b.Skipf("keygen(%s,T=%d,N=%d): %v", mode, t, n, err)
|
||||
}
|
||||
hp, err := deriveHyperballParams(mode, t, n)
|
||||
if err != nil {
|
||||
b.Skipf("deriveHyperballParams(%s,T=%d,N=%d): %v", mode, t, n, err)
|
||||
}
|
||||
active := canonicalActive(t)
|
||||
|
||||
// Warm one sign: confirm viability and capture the derived shape.
|
||||
warmRNG := newBCCDeterministicRNG(fmt.Sprintf("HB/WARM/%s/%d-%d", mode, t, n))
|
||||
sig, tr, err := mk.SignHyperball(active, msg, ctx, warmRNG, hyperballSignBudget)
|
||||
if err != nil {
|
||||
b.Skipf("warm SignHyperball(%s,T=%d,N=%d): %v", mode, t, n, err)
|
||||
}
|
||||
|
||||
var totalRounds int
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
r := newBCCDeterministicRNG(fmt.Sprintf("HB/%s/%d-%d/%d", mode, t, n, i))
|
||||
_, tri, err := mk.SignHyperball(active, msg, ctx, r, hyperballSignBudget)
|
||||
if err != nil {
|
||||
// Large committees can intermittently exhaust the K-clamped
|
||||
// slot budget — a documented scaling limit, not a bench fault.
|
||||
b.Skipf("SignHyperball(%s,T=%d,N=%d) exhausted at iter %d: %v", mode, t, n, i, err)
|
||||
}
|
||||
totalRounds += tri.Rounds
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
b.ReportMetric(float64(hp.kReps), "K_slots")
|
||||
b.ReportMetric(float64(maxSubsetsPerParty(t, n)), "max_subsets")
|
||||
if b.N > 0 {
|
||||
b.ReportMetric(float64(totalRounds)/float64(b.N), "rounds/op")
|
||||
}
|
||||
b.ReportMetric(float64(tr.Rounds), "warm_rounds")
|
||||
b.ReportMetric(float64(len(sig.Bytes)), "sig_bytes")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHyperballReconstructTax measures MithrilKey.Sign — the RECONSTRUCT
|
||||
// path (rebuilds the full key at the coordinator via ReconstructKeyMaterial,
|
||||
// then bccSign). The delta to BenchmarkHyperballNoReconstructSign on the same
|
||||
// (mode, family) is the no-reconstruct tax: what the trustless signer pays in
|
||||
// compute to never form the whole key. Keygen is setup; the timed op is Sign.
|
||||
func BenchmarkHyperballReconstructTax(b *testing.B) {
|
||||
msg := []byte("Quasar finality subject M — reconstruct-path sign baseline")
|
||||
ctx := []byte("quasar-pulsar-leg")
|
||||
|
||||
for _, tc := range hyperballSignCases() {
|
||||
mode, t, n := tc.mode, tc.t, tc.n
|
||||
b.Run(fmt.Sprintf("%s_N%d_T%d", mode, n, t), func(b *testing.B) {
|
||||
mk, err := MithrilRSSKeygen(mode, t, n, mithrilSeeds(n))
|
||||
if err != nil {
|
||||
b.Skipf("keygen(%s,T=%d,N=%d): %v", mode, t, n, err)
|
||||
}
|
||||
active := canonicalActive(t)
|
||||
warmRNG := newBCCDeterministicRNG(fmt.Sprintf("RECON/WARM/%s/%d-%d", mode, t, n))
|
||||
if _, err := mk.Sign(active, msg, ctx, warmRNG, reconstructBudget); err != nil {
|
||||
b.Skipf("warm Sign(%s,T=%d,N=%d): %v", mode, t, n, err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
r := newBCCDeterministicRNG(fmt.Sprintf("RECON/%s/%d-%d/%d", mode, t, n, i))
|
||||
if _, err := mk.Sign(active, msg, ctx, r, reconstructBudget); err != nil {
|
||||
b.Skipf("Sign(%s,T=%d,N=%d) exhausted at iter %d: %v", mode, t, n, i, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user