Skip to content

Fix MultiScalarMul small-n pathology and NewRandomZr allocation regression - #55

Open
adecaro wants to merge 2 commits into
mainfrom
fix/msm-and-random-zr-perf
Open

Fix MultiScalarMul small-n pathology and NewRandomZr allocation regression#55
adecaro wants to merge 2 commits into
mainfrom
fix/msm-and-random-zr-perf

Conversation

@adecaro

@adecaro adecaro commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

  • MultiScalarMul on the gnark-backed drivers (bls12381, bn254, bls12-377) always dispatched to gnark's bucket-method MultiExp, whose fixed setup cost (window/chunk sizing, goroutine fan-out) is tuned for thousands of points. At n=1 this is ~4x slower than a plain Mul (280µs vs 68µs, 144 vs 13 allocs). Add a pairwise Mul2+Add fallback below a measured n=7 crossover point on all three drivers, keeping MultiExp unchanged at/above the threshold (no regression at large n).
  • Fixed a latent correctness bug this dispatch change exposed: the bls12381 MultiScalarMul read Zr.val directly instead of going through toBigInt, silently treating GroupOrder scalars as zero. Both the new pairwise branch and the MultiExp branch now route through toBigInt, matching what Mul/Mul2 already did.
  • NewRandomZr on bls12381 regressed in 0148cd9 (which correctly fixed it to honor the caller's rng instead of crypto/rand) by reaching for a big.Int-based rejection sampler (5 allocs/184B). Restored an allocation-free implementation using stack-buffer rejection sampling + fr.Element.SetBytesCanonical, the same strategy gnark's own SetRandom uses internally, while still reading exclusively from rng (2 allocs/80B).
  • Tightened the NewRandomZr doc contracts (math.go, driver/math.go) to state that rng must be the exclusive entropy source, so this isn't silently "fixed" back.
  • Investigated but did not change: the kilic→gnark hash-to-curve backend swap in 4952af1 was suspected as a possible source of regression. Measured A/B on 4952af1^ (kilic vs gnark in the same binary) shows gnark's HashToG1 is actually ~1.9x faster (76µs vs 141µs) — not a regression, no action needed.

Test plan

  • go build ./..., go vet ./..., gofmt -l -s . clean
  • go test ./... and go test -race -cover ./... pass
  • golangci-lint run — 0 issues
  • addlicense -check — clean
  • go fix -diff ./... — emits nothing
  • New tests: MultiScalarMul dispatch-boundary sweep (n=0,1,2,6,7,8,10,33) across all curves, zero-scalar/infinity-base case, GroupOrder scalar correctness at both the pairwise and MultiExp branch; NewRandomZr bad-reader panic and distribution sanity check
  • benchstat before/after: MultiScalarMul n=1 now within noise of BenchmarkG1Mul, n=2-6 show large allocation/time wins, n≥7 unchanged; NewRandomZr back to ~1 allocation
  • End-to-end against IBM/idemix (go mod edit -replace): BenchmarkSignerSign/EidNymRhNym unchanged (~3.5ms), and idemix's own MSM-vs-pairwise crossover benchmark shows the gap closed at small n — confirming this subsumes idemix's client-side workaround in bbs/bbs12381g2pub.go

…ssion

MultiScalarMul dispatched every call to gnark's bucket-method MultiExp,
whose large fixed setup cost (window/chunk sizing, goroutine fan-out) made
it ~4x slower than a single Mul at n=1 and dominates small inputs generally.
Add a pairwise Mul2+Add fallback below a measured n=7 crossover on the three
gnark-backed drivers (bls12381, bn254, bls12-377), and route the MultiExp
scalar conversion through Zr.toBigInt so GroupOrder scalars are no longer
silently read as zero.

NewRandomZr on bls12381 regressed to a big.Int-based rejection sampler when
0148cd9 fixed it to honor the caller's rng; restore an allocation-free
stack-buffer implementation using fr.Element.SetBytesCanonical while keeping
rng as the sole entropy source.

Also measured (no fix needed): the kilic->gnark hash-to-curve swap in
4952af1 is ~1.9x faster with gnark, not a regression.
The small-n pairwise dispatch added to the gurvy drivers' MultiScalarMul was
tuned on single-threaded microbenchmarks (go test -bench -cpu 1), where the
fixed cost of gnark's bucket-method MultiExp - window/chunk setup plus a
goroutine fan-out sized for runtime.NumCPU() - dominates for a handful of
bases. Under real concurrent load that reasoning inverts: the fan-out only
pays off when spare cores exist, and a saturated process has none.

It also silently overrode callers that had already made a deliberate choice
for their own sizes. Panurus' CSP range proof, for instance, special-cases
n=1 and n=2 with Mul/Mul2 and hands everything from n=3 up to MultiScalarMul
on the strength of its own measurements; the threshold of 7 rerouted n=3..6
back to a pairwise loop behind its back.

Drop the threshold and both pairwise helpers. MultiScalarMul now keeps only
the trivial n==0 and n==1 guards and otherwise goes straight to MultiExp,
so a caller that knows its sizes can pick Mul/Mul2 itself and a caller that
asks for a multi-scalar multiplication gets one. The doc comments point at
that division of labor, and the benchmark/test comments that described the
now-removed dispatch boundary are updated to match.

No behavioral change: both paths compute the same sum, and the GroupOrder
consistency test still covers a small-n and a large-n size.

Signed-off-by: Angelo De Caro <adc@zurich.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant