From e5e883b2bc60a8fbea75c87a9f5e9f5e52d5d48c Mon Sep 17 00:00:00 2001 From: awn Date: Fri, 21 Aug 2026 17:28:15 +0530 Subject: [PATCH 1/4] perf(ecc): break MultiExp window ties toward the larger window MultiExp picks the pippenger window size c by minimising estimated group operation count: `cost(c) = (fr.Bits + 1) * (nbPoints + 2^c) / c` The comparison was strict and the loop ascends over the implemented window sizes, so whenever two window sizes score identically the smaller one won. Setting cost(c) = cost(c+1) makes the (fr.Bits + 1) factor cancel, and for a contiguous window set the model is exactly indifferent at nbPoints = 2^c * (c - 1). Windows c <= 9 use the extended Jacobian chunk processor, while c >= 10 use the affine one to kill single field inversion over the bunch of bucket additions. Inversion costs roughly 84 muls spreading it across 80 to 640 bucket additions -> affine addition..cheaper than the Jacobian mixed adds The cost model counts a bucket accumulation and a bucket reduction as one group operation each and cannot see any of this Exactly one tie per curve crosses the Jacobian/batch-affine boundary-> n = 4096 c 9 -> 10 bn254, bls12-377, bls12-381, bls24-315, bls24-317, grumpkin, stark-curve, secp256k1, secp256r1 n = 2816 c 8 -> 10 bw6-761 n = 7424 c 8 -> 12 bw6-633 4096 is ScalarsPerBlob, so the eip4844 commitment and proof paths sat exactly on the wrong side of this tie. Measured on my apple M2, BLS12-381 G1, min of 5 runs: n=4095 tasks=1 76.73 ms -> 76.66 ms (control, c unchanged) n=4096 tasks=1 76.65 ms -> 64.76 ms -15.5% n=4097 tasks=1 64.74 ms -> 64.73 ms (control, c unchanged) n=4096 tasks=16 13.12 ms -> 10.27 ms -21.7% --- ecc/bls12-377/multiexp.go | 18 ++++++++++++++++-- ecc/bls12-381/multiexp.go | 18 ++++++++++++++++-- ecc/bls24-315/multiexp.go | 18 ++++++++++++++++-- ecc/bls24-317/multiexp.go | 18 ++++++++++++++++-- ecc/bn254/multiexp.go | 18 ++++++++++++++++-- ecc/bw6-633/multiexp.go | 18 ++++++++++++++++-- ecc/bw6-761/multiexp.go | 18 ++++++++++++++++-- ecc/grumpkin/multiexp.go | 9 ++++++++- ecc/secp256k1/multiexp.go | 9 ++++++++- ecc/secp256r1/multiexp.go | 9 ++++++++- ecc/stark-curve/multiexp.go | 9 ++++++++- .../generator/ecc/template/multiexp.go.tmpl | 9 ++++++++- 12 files changed, 152 insertions(+), 19 deletions(-) diff --git a/ecc/bls12-377/multiexp.go b/ecc/bls12-377/multiexp.go index 68959b0d9e..eef6ebe9c4 100644 --- a/ecc/bls12-377/multiexp.go +++ b/ecc/bls12-377/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } @@ -408,7 +415,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/ecc/bls12-381/multiexp.go b/ecc/bls12-381/multiexp.go index 03aa684ddf..fe249f30bb 100644 --- a/ecc/bls12-381/multiexp.go +++ b/ecc/bls12-381/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } @@ -408,7 +415,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/ecc/bls24-315/multiexp.go b/ecc/bls24-315/multiexp.go index a4720e343e..6adcaf91d9 100644 --- a/ecc/bls24-315/multiexp.go +++ b/ecc/bls24-315/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } @@ -408,7 +415,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/ecc/bls24-317/multiexp.go b/ecc/bls24-317/multiexp.go index 11b7b3199e..90c555de2c 100644 --- a/ecc/bls24-317/multiexp.go +++ b/ecc/bls24-317/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } @@ -408,7 +415,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/ecc/bn254/multiexp.go b/ecc/bn254/multiexp.go index fd4f03c2dd..07b82d01eb 100644 --- a/ecc/bn254/multiexp.go +++ b/ecc/bn254/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } @@ -410,7 +417,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/ecc/bw6-633/multiexp.go b/ecc/bw6-633/multiexp.go index ff153b9df5..b5d1ab304b 100644 --- a/ecc/bw6-633/multiexp.go +++ b/ecc/bw6-633/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } @@ -357,7 +364,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/ecc/bw6-761/multiexp.go b/ecc/bw6-761/multiexp.go index d2f9aa1ffd..04d3057501 100644 --- a/ecc/bw6-761/multiexp.go +++ b/ecc/bw6-761/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } @@ -359,7 +366,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/ecc/grumpkin/multiexp.go b/ecc/grumpkin/multiexp.go index 6fbd0930ac..ffa7fe14ec 100644 --- a/ecc/grumpkin/multiexp.go +++ b/ecc/grumpkin/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/ecc/secp256k1/multiexp.go b/ecc/secp256k1/multiexp.go index 556163d4d1..76bb645265 100644 --- a/ecc/secp256k1/multiexp.go +++ b/ecc/secp256k1/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/ecc/secp256r1/multiexp.go b/ecc/secp256r1/multiexp.go index 1e1ef4adc0..adbedfc9af 100644 --- a/ecc/secp256r1/multiexp.go +++ b/ecc/secp256r1/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/ecc/stark-curve/multiexp.go b/ecc/stark-curve/multiexp.go index 67a5ad1254..39a2d6acea 100644 --- a/ecc/stark-curve/multiexp.go +++ b/ecc/stark-curve/multiexp.go @@ -85,7 +85,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul for _, c := range implementedCs { cc := (fr.Bits + 1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } diff --git a/internal/generator/ecc/template/multiexp.go.tmpl b/internal/generator/ecc/template/multiexp.go.tmpl index 261471e5c1..f28a1d0e5c 100644 --- a/internal/generator/ecc/template/multiexp.go.tmpl +++ b/internal/generator/ecc/template/multiexp.go.tmpl @@ -295,7 +295,14 @@ func (p *{{ $.TJacobian }}) MultiExp(points []{{ $.TAffine }}, scalars []fr.Elem for _, c := range implementedCs { cc := (fr.Bits+1) * (nbPoints + (1 << c)) cost := float64(cc) / float64(c) - if cost < min { + // on ties, prefer the larger window. The cost model counts a bucket + // accumulation and a bucket reduction as one group operation each, but + // windows c >= 10 use the batch-affine chunk processor, which amortises + // a single field inversion over a whole batch of bucket additions and is + // substantially cheaper per point than the extended-Jacobian path used + // for c <= 9. The model cannot see that, so a tie is never actually a tie: + // the larger c is the faster one. + if cost <= min { min = cost C = c } From b0eb0d5ca72b1526e08212606fbbcf3099bf6568 Mon Sep 17 00:00:00 2001 From: awn Date: Fri, 21 Aug 2026 18:01:22 +0530 Subject: [PATCH 2/4] test(ecc): cover the MultiExp window tie-break Extract bestC from MultiExp into a package-level bestCG1/bestCG2 so the window selection is reachable from a test, then assert it returns the largest cost-minimising window. Co-Authored-By: Claude Opus 5 --- ecc/bls12-377/multiexp.go | 128 +++++++++-------- ecc/bls12-377/multiexp_test.go | 132 ++++++++++++++++++ ecc/bls12-381/multiexp.go | 128 +++++++++-------- ecc/bls12-381/multiexp_test.go | 132 ++++++++++++++++++ ecc/bls24-315/multiexp.go | 128 +++++++++-------- ecc/bls24-315/multiexp_test.go | 132 ++++++++++++++++++ ecc/bls24-317/multiexp.go | 128 +++++++++-------- ecc/bls24-317/multiexp_test.go | 132 ++++++++++++++++++ ecc/bn254/multiexp.go | 128 +++++++++-------- ecc/bn254/multiexp_test.go | 132 ++++++++++++++++++ ecc/bw6-633/multiexp.go | 128 +++++++++-------- ecc/bw6-633/multiexp_test.go | 132 ++++++++++++++++++ ecc/bw6-761/multiexp.go | 128 +++++++++-------- ecc/bw6-761/multiexp_test.go | 132 ++++++++++++++++++ ecc/grumpkin/multiexp.go | 64 +++++---- ecc/grumpkin/multiexp_test.go | 66 +++++++++ ecc/secp256k1/multiexp.go | 64 +++++---- ecc/secp256k1/multiexp_test.go | 66 +++++++++ ecc/secp256r1/multiexp.go | 64 +++++---- ecc/secp256r1/multiexp_test.go | 66 +++++++++ ecc/stark-curve/multiexp.go | 64 +++++---- ecc/stark-curve/multiexp_test.go | 66 +++++++++ .../generator/ecc/template/multiexp.go.tmpl | 68 +++++---- .../ecc/template/tests/multiexp.go.tmpl | 68 +++++++++ 24 files changed, 1923 insertions(+), 553 deletions(-) diff --git a/ecc/bls12-377/multiexp.go b/ecc/bls12-377/multiexp.go index eef6ebe9c4..3ef6a81d66 100644 --- a/ecc/bls12-377/multiexp.go +++ b/ecc/bls12-377/multiexp.go @@ -73,34 +73,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // here, we compute the best C for nbPoints // we split recursively until nbChunks(c) >= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) @@ -403,34 +409,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // here, we compute the best C for nbPoints // we split recursively until nbChunks(c) >= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG2(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -458,7 +437,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -483,6 +462,39 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG2 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG2(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG2(p *G2Jac, c uint64, points []G2Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G2Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/bls12-377/multiexp_test.go b/ecc/bls12-377/multiexp_test.go index 8bbffcba67..8494cc70e8 100644 --- a/ecc/bls12-377/multiexp_test.go +++ b/ecc/bls12-377/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) @@ -403,34 +409,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // here, we compute the best C for nbPoints // we split recursively until nbChunks(c) >= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG2(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -458,7 +437,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -483,6 +462,39 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG2 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG2(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG2(p *G2Jac, c uint64, points []G2Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G2Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/bls12-381/multiexp_test.go b/ecc/bls12-381/multiexp_test.go index 98ac34218b..fc523fba2c 100644 --- a/ecc/bls12-381/multiexp_test.go +++ b/ecc/bls12-381/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) @@ -403,34 +409,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // here, we compute the best C for nbPoints // we split recursively until nbChunks(c) >= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG2(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -458,7 +437,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -483,6 +462,39 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG2 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG2(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG2(p *G2Jac, c uint64, points []G2Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G2Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/bls24-315/multiexp_test.go b/ecc/bls24-315/multiexp_test.go index 259af5ae8a..c931c0dcc8 100644 --- a/ecc/bls24-315/multiexp_test.go +++ b/ecc/bls24-315/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) @@ -403,34 +409,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // here, we compute the best C for nbPoints // we split recursively until nbChunks(c) >= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG2(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -458,7 +437,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -483,6 +462,39 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG2 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG2(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG2(p *G2Jac, c uint64, points []G2Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G2Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/bls24-317/multiexp_test.go b/ecc/bls24-317/multiexp_test.go index 70594fec8e..de703e86fa 100644 --- a/ecc/bls24-317/multiexp_test.go +++ b/ecc/bls24-317/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) @@ -405,34 +411,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // here, we compute the best C for nbPoints // we split recursively until nbChunks(c) >= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG2(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -460,7 +439,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -485,6 +464,39 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG2 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG2(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG2(p *G2Jac, c uint64, points []G2Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G2Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/bn254/multiexp_test.go b/ecc/bn254/multiexp_test.go index 35560e18f2..e16f4e280d 100644 --- a/ecc/bn254/multiexp_test.go +++ b/ecc/bn254/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 8, 12, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 8, 12, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) @@ -352,34 +358,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // here, we compute the best C for nbPoints // we split recursively until nbChunks(c) >= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 8, 12, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG2(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -407,7 +386,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -432,6 +411,39 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG2 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG2(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 8, 12, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG2(p *G2Jac, c uint64, points []G2Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G2Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/bw6-633/multiexp_test.go b/ecc/bw6-633/multiexp_test.go index 838a8de6a1..57083720c0 100644 --- a/ecc/bw6-633/multiexp_test.go +++ b/ecc/bw6-633/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 8, 12, 16} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 8, 10, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 8, 10, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) @@ -354,34 +360,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // here, we compute the best C for nbPoints // we split recursively until nbChunks(c) >= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 8, 10, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG2(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -409,7 +388,7 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -434,6 +413,39 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG2 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG2(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 8, 10, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG2(p *G2Jac, c uint64, points []G2Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G2Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/bw6-761/multiexp_test.go b/ecc/bw6-761/multiexp_test.go index 797fc89023..6444c81a38 100644 --- a/ecc/bw6-761/multiexp_test.go +++ b/ecc/bw6-761/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 8, 10, 16} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/grumpkin/multiexp_test.go b/ecc/grumpkin/multiexp_test.go index 274787b5d6..d6c56a8e14 100644 --- a/ecc/grumpkin/multiexp_test.go +++ b/ecc/grumpkin/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/secp256k1/multiexp_test.go b/ecc/secp256k1/multiexp_test.go index b0d0f598d6..4d9a6b6215 100644 --- a/ecc/secp256k1/multiexp_test.go +++ b/ecc/secp256k1/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/secp256r1/multiexp_test.go b/ecc/secp256r1/multiexp_test.go index 14003ea63d..b1081f5ad6 100644 --- a/ecc/secp256r1/multiexp_test.go +++ b/ecc/secp256r1/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestCG1(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -128,7 +101,7 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints / 2) + cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -153,6 +126,39 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return p, nil } +// bestCG1 returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestCG1(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsmG1(p *G1Jac, c uint64, points []G1Affine, scalars []fr.Element, config ecc.MultiExpConfig) *G1Jac { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/ecc/stark-curve/multiexp_test.go b/ecc/stark-curve/multiexp_test.go index d151d0ae57..2e8e638476 100644 --- a/ecc/stark-curve/multiexp_test.go +++ b/ecc/stark-curve/multiexp_test.go @@ -20,6 +20,72 @@ import ( "github.com/leanovate/gopter/prop" ) +func TestBestCG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + + // mirrors the cost model used by bestCG1 + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestCG1(nbPoints); got != want { + t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1<= nbTasks, - bestC := func(nbPoints int) uint64 { - // implemented msmC methods (the c we use must be in this slice) - implementedCs := []uint64{ - {{- range $c := $.CRange}}{{- if ge $c 4}}{{$c}},{{- end}}{{- end}} - } - var C uint64 - // approximate cost (in group operations) - // cost = bits/c * (nbPoints + 2^{c}) - // this needs to be verified empirically. - // for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results - min := math.MaxFloat64 - for _, c := range implementedCs { - cc := (fr.Bits+1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - // on ties, prefer the larger window. The cost model counts a bucket - // accumulation and a bucket reduction as one group operation each, but - // windows c >= 10 use the batch-affine chunk processor, which amortises - // a single field inversion over a whole batch of bucket additions and is - // substantially cheaper per point than the extended-Jacobian path used - // for c <= 9. The model cannot see that, so a tie is never actually a tie: - // the larger c is the faster one. - if cost <= min { - min = cost - C = c - } - } - return C - } - - C := bestC(nbPoints) + C := bestC{{ $.UPointName }}(nbPoints) nbChunks := int(computeNbChunks(C)) // should we recursively split the msm in half? (see below) @@ -338,7 +309,7 @@ func (p *{{ $.TJacobian }}) MultiExp(points []{{ $.TAffine }}, scalars []fr.Elem costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) - cPostSplit := bestC(nbPoints/2) + cPostSplit := bestC{{ $.UPointName }}(nbPoints/2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) costPostSplit := costFunction(nbChunksPostSplit * 2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) @@ -363,6 +334,41 @@ func (p *{{ $.TJacobian }}) MultiExp(points []{{ $.TAffine }}, scalars []fr.Elem return p, nil } +// bestC{{ $.UPointName }} returns the window size c to use for a msm of size nbPoints. +// +// It minimises an approximate group-operation count: +// +// cost = bits/c * (nbPoints + 2^{c}) +// +// this needs to be verified empirically. +// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// +// When several window sizes score the same, the largest is returned. Such ties +// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is +// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk +// processor, which amortises a single field inversion over a whole batch of +// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model +// counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference, but the larger c is the faster one. +func bestC{{ $.UPointName }}(nbPoints int) uint64 { + // implemented msmC methods (the c we use must be in this slice) + implementedCs := []uint64{ + {{- range $c := $.CRange}}{{- if ge $c 4}}{{$c}},{{- end}}{{- end}} + } + var C uint64 + min := math.MaxFloat64 + for _, c := range implementedCs { + cc := (fr.Bits+1) * (nbPoints + (1 << c)) + cost := float64(cc) / float64(c) + if cost <= min { + min = cost + C = c + } + } + return C +} + func _innerMsm{{ $.UPointName }}(p *{{ $.TJacobian }}, c uint64, points []{{ $.TAffine }}, scalars []fr.Element, config ecc.MultiExpConfig) *{{ $.TJacobian }} { // partition the scalars digits, chunkStats := partitionScalars(scalars, c, config.NbTasks) diff --git a/internal/generator/ecc/template/tests/multiexp.go.tmpl b/internal/generator/ecc/template/tests/multiexp.go.tmpl index 665de10ea6..2b579882cf 100644 --- a/internal/generator/ecc/template/tests/multiexp.go.tmpl +++ b/internal/generator/ecc/template/tests/multiexp.go.tmpl @@ -30,6 +30,74 @@ import ( {{define "multiexp" }} +func TestBestC{{$.UPointName}}(t *testing.T) { + implementedCs := []uint64{ + {{- range $c := $.CRange}}{{- if ge $c 4}}{{$c}},{{- end}}{{- end}} + } + + // mirrors the cost model used by bestC{{$.UPointName}} + cost := func(nbPoints int, c uint64) float64 { + cc := (fr.Bits + 1) * (nbPoints + (1 << c)) + return float64(cc) / float64(c) + } + + // bestC must return the largest window size among those minimising the cost. + // Where the model ties, the smaller c selects the extended-Jacobian chunk + // processor instead of the batch-affine one and is measurably slower. + assertLargestMinimiser := func(nbPoints int) { + t.Helper() + best := cost(nbPoints, implementedCs[0]) + for _, c := range implementedCs[1:] { + if v := cost(nbPoints, c); v < best { + best = v + } + } + var want uint64 + for _, c := range implementedCs { + if cost(nbPoints, c) == best { + want = c // implementedCs is ascending, so this ends on the largest + } + } + if got := bestC{{$.UPointName}}(nbPoints); got != want { + t.Fatalf("bestC{{$.UPointName}}(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) + } + } + + sweep := 1 << 16 + if testing.Short() { + sweep = 1 << 12 + } + for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + assertLargestMinimiser(nbPoints) + } + + // exercise the exact tie points, including any beyond the sweep range. + // for consecutive window sizes c1 < c2 the model is indifferent at + // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + nbTies := 0 + for i := 0; i+1 < len(implementedCs); i++ { + c1, c2 := implementedCs[i], implementedCs[i+1] + num := int(c1)*(1< Date: Sat, 22 Aug 2026 13:05:50 +0530 Subject: [PATCH 3/4] docs(ecc): scope the MultiExp tie-break rationale correctly Only one tie per curve crosses the Jacobian/batch-affine boundary, not all. --- ecc/bls12-377/multiexp.go | 32 +++++++++++++------ ecc/bls12-377/multiexp_test.go | 10 +++--- ecc/bls12-381/multiexp.go | 32 +++++++++++++------ ecc/bls12-381/multiexp_test.go | 10 +++--- ecc/bls24-315/multiexp.go | 32 +++++++++++++------ ecc/bls24-315/multiexp_test.go | 10 +++--- ecc/bls24-317/multiexp.go | 32 +++++++++++++------ ecc/bls24-317/multiexp_test.go | 10 +++--- ecc/bn254/multiexp.go | 32 +++++++++++++------ ecc/bn254/multiexp_test.go | 10 +++--- ecc/bw6-633/multiexp.go | 32 +++++++++++++------ ecc/bw6-633/multiexp_test.go | 10 +++--- ecc/bw6-761/multiexp.go | 32 +++++++++++++------ ecc/bw6-761/multiexp_test.go | 10 +++--- ecc/grumpkin/multiexp.go | 16 +++++++--- ecc/grumpkin/multiexp_test.go | 5 +-- ecc/secp256k1/multiexp.go | 16 +++++++--- ecc/secp256k1/multiexp_test.go | 5 +-- ecc/secp256r1/multiexp.go | 16 +++++++--- ecc/secp256r1/multiexp_test.go | 5 +-- ecc/stark-curve/multiexp.go | 16 +++++++--- ecc/stark-curve/multiexp_test.go | 5 +-- .../generator/ecc/template/multiexp.go.tmpl | 16 +++++++--- .../ecc/template/tests/multiexp.go.tmpl | 5 +-- 24 files changed, 266 insertions(+), 133 deletions(-) diff --git a/ecc/bls12-377/multiexp.go b/ecc/bls12-377/multiexp.go index 3ef6a81d66..9b1a86a12d 100644 --- a/ecc/bls12-377/multiexp.go +++ b/ecc/bls12-377/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} @@ -474,11 +480,17 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} diff --git a/ecc/bls12-377/multiexp_test.go b/ecc/bls12-377/multiexp_test.go index 8494cc70e8..949832f7e2 100644 --- a/ecc/bls12-377/multiexp_test.go +++ b/ecc/bls12-377/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) @@ -510,8 +511,9 @@ func TestBestCG2(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG2 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/ecc/bls12-381/multiexp.go b/ecc/bls12-381/multiexp.go index 1ecf0b4515..743e14842c 100644 --- a/ecc/bls12-381/multiexp.go +++ b/ecc/bls12-381/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} @@ -474,11 +480,17 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} diff --git a/ecc/bls12-381/multiexp_test.go b/ecc/bls12-381/multiexp_test.go index fc523fba2c..b7e8730f0c 100644 --- a/ecc/bls12-381/multiexp_test.go +++ b/ecc/bls12-381/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) @@ -510,8 +511,9 @@ func TestBestCG2(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG2 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/ecc/bls24-315/multiexp.go b/ecc/bls24-315/multiexp.go index 96404f9be2..1ca6acbc40 100644 --- a/ecc/bls24-315/multiexp.go +++ b/ecc/bls24-315/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} @@ -474,11 +480,17 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} diff --git a/ecc/bls24-315/multiexp_test.go b/ecc/bls24-315/multiexp_test.go index c931c0dcc8..5462a14dcc 100644 --- a/ecc/bls24-315/multiexp_test.go +++ b/ecc/bls24-315/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) @@ -510,8 +511,9 @@ func TestBestCG2(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG2 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/ecc/bls24-317/multiexp.go b/ecc/bls24-317/multiexp.go index e05f9b65cc..02c401f260 100644 --- a/ecc/bls24-317/multiexp.go +++ b/ecc/bls24-317/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} @@ -474,11 +480,17 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} diff --git a/ecc/bls24-317/multiexp_test.go b/ecc/bls24-317/multiexp_test.go index de703e86fa..03e3e8152e 100644 --- a/ecc/bls24-317/multiexp_test.go +++ b/ecc/bls24-317/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) @@ -510,8 +511,9 @@ func TestBestCG2(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG2 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/ecc/bn254/multiexp.go b/ecc/bn254/multiexp.go index 80a0e94f80..4f7c32a408 100644 --- a/ecc/bn254/multiexp.go +++ b/ecc/bn254/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} @@ -476,11 +482,17 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} diff --git a/ecc/bn254/multiexp_test.go b/ecc/bn254/multiexp_test.go index e16f4e280d..5a7d42f563 100644 --- a/ecc/bn254/multiexp_test.go +++ b/ecc/bn254/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) @@ -510,8 +511,9 @@ func TestBestCG2(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG2 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/ecc/bw6-633/multiexp.go b/ecc/bw6-633/multiexp.go index ff20613839..f516d20cb2 100644 --- a/ecc/bw6-633/multiexp.go +++ b/ecc/bw6-633/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 8, 12, 16} @@ -423,11 +429,17 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 8, 12, 16} diff --git a/ecc/bw6-633/multiexp_test.go b/ecc/bw6-633/multiexp_test.go index 57083720c0..d44a7e51f7 100644 --- a/ecc/bw6-633/multiexp_test.go +++ b/ecc/bw6-633/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) @@ -510,8 +511,9 @@ func TestBestCG2(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG2 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/ecc/bw6-761/multiexp.go b/ecc/bw6-761/multiexp.go index bd1715c5d4..b18531b437 100644 --- a/ecc/bw6-761/multiexp.go +++ b/ecc/bw6-761/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 8, 10, 16} @@ -425,11 +431,17 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 8, 10, 16} diff --git a/ecc/bw6-761/multiexp_test.go b/ecc/bw6-761/multiexp_test.go index 6444c81a38..3a8c133e51 100644 --- a/ecc/bw6-761/multiexp_test.go +++ b/ecc/bw6-761/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) @@ -510,8 +511,9 @@ func TestBestCG2(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG2 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/ecc/grumpkin/multiexp.go b/ecc/grumpkin/multiexp.go index 1cfc0a10c8..0a693de4c3 100644 --- a/ecc/grumpkin/multiexp.go +++ b/ecc/grumpkin/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} diff --git a/ecc/grumpkin/multiexp_test.go b/ecc/grumpkin/multiexp_test.go index d6c56a8e14..a7946237a5 100644 --- a/ecc/grumpkin/multiexp_test.go +++ b/ecc/grumpkin/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/ecc/secp256k1/multiexp.go b/ecc/secp256k1/multiexp.go index e1cc79c9a6..6cd2c42b70 100644 --- a/ecc/secp256k1/multiexp.go +++ b/ecc/secp256k1/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} diff --git a/ecc/secp256k1/multiexp_test.go b/ecc/secp256k1/multiexp_test.go index 4d9a6b6215..45ea32776f 100644 --- a/ecc/secp256k1/multiexp_test.go +++ b/ecc/secp256k1/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/ecc/secp256r1/multiexp.go b/ecc/secp256r1/multiexp.go index ed8da63860..ef3c8c27e1 100644 --- a/ecc/secp256r1/multiexp.go +++ b/ecc/secp256r1/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} diff --git a/ecc/secp256r1/multiexp_test.go b/ecc/secp256r1/multiexp_test.go index b1081f5ad6..49e4dae52b 100644 --- a/ecc/secp256r1/multiexp_test.go +++ b/ecc/secp256r1/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/ecc/stark-curve/multiexp.go b/ecc/stark-curve/multiexp.go index 2d751923c3..4905eec7cb 100644 --- a/ecc/stark-curve/multiexp.go +++ b/ecc/stark-curve/multiexp.go @@ -138,11 +138,17 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} diff --git a/ecc/stark-curve/multiexp_test.go b/ecc/stark-curve/multiexp_test.go index 2e8e638476..35af961080 100644 --- a/ecc/stark-curve/multiexp_test.go +++ b/ecc/stark-curve/multiexp_test.go @@ -30,8 +30,9 @@ func TestBestCG1(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestCG1 for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) diff --git a/internal/generator/ecc/template/multiexp.go.tmpl b/internal/generator/ecc/template/multiexp.go.tmpl index 2e37196012..68c309b87a 100644 --- a/internal/generator/ecc/template/multiexp.go.tmpl +++ b/internal/generator/ecc/template/multiexp.go.tmpl @@ -346,11 +346,17 @@ func (p *{{ $.TJacobian }}) MultiExp(points []{{ $.TAffine }}, scalars []fr.Elem // When several window sizes score the same, the largest is returned. Such ties // are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. They are not ties in practice: windows c >= 10 use the batch-affine chunk -// processor, which amortises a single field inversion over a whole batch of -// bucket additions, while c <= 9 uses the extended-Jacobian one. The cost model -// counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference, but the larger c is the faster one. +// c1 + 1. +// +// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 +// use the batch-affine one, which amortises a single field inversion over a whole +// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost +// model counts a bucket accumulation and a bucket reduction as one group operation +// each and so cannot see that difference; at that tie the larger window is +// measurably the faster one. The remaining ties fall wholly inside one processor +// or the other, where the model has no known bias; the larger window is taken +// there too, so the rule stays uniform and because it yields fewer chunks, hence +// fewer goroutines and channels per msm. func bestC{{ $.UPointName }}(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{ diff --git a/internal/generator/ecc/template/tests/multiexp.go.tmpl b/internal/generator/ecc/template/tests/multiexp.go.tmpl index 2b579882cf..5fc05af9c9 100644 --- a/internal/generator/ecc/template/tests/multiexp.go.tmpl +++ b/internal/generator/ecc/template/tests/multiexp.go.tmpl @@ -42,8 +42,9 @@ func TestBestC{{$.UPointName}}(t *testing.T) { } // bestC must return the largest window size among those minimising the cost. - // Where the model ties, the smaller c selects the extended-Jacobian chunk - // processor instead of the batch-affine one and is measurably slower. + // See bestC{{$.UPointName}} for why ties resolve upward; the one that matters + // most is the tie crossing from the extended-Jacobian chunk processor to the + // batch-affine one, where the smaller c is measurably slower. assertLargestMinimiser := func(nbPoints int) { t.Helper() best := cost(nbPoints, implementedCs[0]) From 19d23dbd7dc0b508a8f0cbca768bf65fee39321b Mon Sep 17 00:00:00 2001 From: awn Date: Sat, 22 Aug 2026 15:45:53 +0530 Subject: [PATCH 4/4] perf(ecc): price the batch-affine chunk processor in the MultiExp cost model Weight a bucket accumulation by the batch its inversion amortises over, use the same model for the split probe, and give costFunction a real CPU count. --- ecc/bls12-377/multiexp.go | 228 ++++++++--- ecc/bls12-377/multiexp_test.go | 360 +++++++++++++++--- ecc/bls12-381/multiexp.go | 228 ++++++++--- ecc/bls12-381/multiexp_test.go | 360 +++++++++++++++--- ecc/bls24-315/multiexp.go | 228 ++++++++--- ecc/bls24-315/multiexp_test.go | 360 +++++++++++++++--- ecc/bls24-317/multiexp.go | 228 ++++++++--- ecc/bls24-317/multiexp_test.go | 360 +++++++++++++++--- ecc/bn254/multiexp.go | 228 ++++++++--- ecc/bn254/multiexp_test.go | 360 +++++++++++++++--- ecc/bw6-633/multiexp.go | 218 ++++++++--- ecc/bw6-633/multiexp_test.go | 360 +++++++++++++++--- ecc/bw6-761/multiexp.go | 218 ++++++++--- ecc/bw6-761/multiexp_test.go | 360 +++++++++++++++--- ecc/grumpkin/multiexp.go | 157 ++++++-- ecc/grumpkin/multiexp_test.go | 181 +++++++-- ecc/secp256k1/multiexp.go | 155 ++++++-- ecc/secp256k1/multiexp_test.go | 181 +++++++-- ecc/secp256r1/multiexp.go | 155 ++++++-- ecc/secp256r1/multiexp_test.go | 181 +++++++-- ecc/stark-curve/multiexp.go | 157 ++++++-- ecc/stark-curve/multiexp_test.go | 181 +++++++-- internal/generator/ecc/generate.go | 21 + .../generator/ecc/template/multiexp.go.tmpl | 149 ++++++-- .../ecc/template/multiexp_affine.go.tmpl | 14 +- .../ecc/template/tests/multiexp.go.tmpl | 185 +++++++-- 26 files changed, 4779 insertions(+), 1034 deletions(-) diff --git a/ecc/bls12-377/multiexp.go b/ecc/bls12-377/multiexp.go index 9b1a86a12d..4655f45bcb 100644 --- a/ecc/bls12-377/multiexp.go +++ b/ecc/bls12-377/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -423,9 +434,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -438,14 +454,25 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG2 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost + + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -470,36 +497,31 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // bestCG2 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG2 +// and process every chunk with the wrong bucket array. // -// cost = bits/c * (nbPoints + 2^{c}) +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results -// -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -727,6 +749,92 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 10: + return 80 + case 11: + return 150 + case 12: + return 200 + case 13: + return 350 + case 14: + return 400 + case 15: + return 500 + case 16: + return 640 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/bls12-377/multiexp_test.go b/ecc/bls12-377/multiexp_test.go index 949832f7e2..595fa00831 100644 --- a/ecc/bls12-377/multiexp_test.go +++ b/ecc/bls12-377/multiexp_test.go @@ -7,9 +7,11 @@ package bls12377 import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() @@ -504,70 +637,201 @@ func fillBenchBasesG1(samplePoints []G1Affine) { func TestBestCG2(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG2 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG2 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG2 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG2(nbPoints); got != want { + got := bestCG2(nbPoints) + if got != want { t.Fatalf("bestCG2(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG2(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG2(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG2(nbPoints); got < prev { + t.Fatalf("bestCG2 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG2(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG2) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG2(was); got < msmBatchAffineC { + t.Fatalf("bestCG2(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG2 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG2(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/ecc/bls12-381/multiexp.go b/ecc/bls12-381/multiexp.go index 743e14842c..8f0ca4a43b 100644 --- a/ecc/bls12-381/multiexp.go +++ b/ecc/bls12-381/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -423,9 +434,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -438,14 +454,25 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG2 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost + + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -470,36 +497,31 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // bestCG2 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG2 +// and process every chunk with the wrong bucket array. // -// cost = bits/c * (nbPoints + 2^{c}) +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results -// -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -727,6 +749,92 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 10: + return 80 + case 11: + return 150 + case 12: + return 200 + case 13: + return 350 + case 14: + return 400 + case 15: + return 500 + case 16: + return 640 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/bls12-381/multiexp_test.go b/ecc/bls12-381/multiexp_test.go index b7e8730f0c..ef0af6a380 100644 --- a/ecc/bls12-381/multiexp_test.go +++ b/ecc/bls12-381/multiexp_test.go @@ -7,9 +7,11 @@ package bls12381 import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() @@ -504,70 +637,201 @@ func fillBenchBasesG1(samplePoints []G1Affine) { func TestBestCG2(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG2 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG2 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG2 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG2(nbPoints); got != want { + got := bestCG2(nbPoints) + if got != want { t.Fatalf("bestCG2(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG2(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG2(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG2(nbPoints); got < prev { + t.Fatalf("bestCG2 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG2(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG2) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG2(was); got < msmBatchAffineC { + t.Fatalf("bestCG2(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG2 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG2(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/ecc/bls24-315/multiexp.go b/ecc/bls24-315/multiexp.go index 1ca6acbc40..1ce425063c 100644 --- a/ecc/bls24-315/multiexp.go +++ b/ecc/bls24-315/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -423,9 +434,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -438,14 +454,25 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG2 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost + + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -470,36 +497,31 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // bestCG2 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG2 +// and process every chunk with the wrong bucket array. // -// cost = bits/c * (nbPoints + 2^{c}) +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results -// -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -727,6 +749,92 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 10: + return 80 + case 11: + return 150 + case 12: + return 200 + case 13: + return 350 + case 14: + return 400 + case 15: + return 500 + case 16: + return 640 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/bls24-315/multiexp_test.go b/ecc/bls24-315/multiexp_test.go index 5462a14dcc..98789ca7d1 100644 --- a/ecc/bls24-315/multiexp_test.go +++ b/ecc/bls24-315/multiexp_test.go @@ -7,9 +7,11 @@ package bls24315 import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() @@ -504,70 +637,201 @@ func fillBenchBasesG1(samplePoints []G1Affine) { func TestBestCG2(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG2 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG2 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG2 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG2(nbPoints); got != want { + got := bestCG2(nbPoints) + if got != want { t.Fatalf("bestCG2(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG2(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG2(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG2(nbPoints); got < prev { + t.Fatalf("bestCG2 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG2(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG2) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG2(was); got < msmBatchAffineC { + t.Fatalf("bestCG2(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG2 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG2(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/ecc/bls24-317/multiexp.go b/ecc/bls24-317/multiexp.go index 02c401f260..b7ee750547 100644 --- a/ecc/bls24-317/multiexp.go +++ b/ecc/bls24-317/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -423,9 +434,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -438,14 +454,25 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG2 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost + + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -470,36 +497,31 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // bestCG2 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG2 +// and process every chunk with the wrong bucket array. // -// cost = bits/c * (nbPoints + 2^{c}) +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results -// -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -727,6 +749,92 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 10: + return 80 + case 11: + return 150 + case 12: + return 200 + case 13: + return 350 + case 14: + return 400 + case 15: + return 500 + case 16: + return 640 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/bls24-317/multiexp_test.go b/ecc/bls24-317/multiexp_test.go index 03e3e8152e..68416c045f 100644 --- a/ecc/bls24-317/multiexp_test.go +++ b/ecc/bls24-317/multiexp_test.go @@ -7,9 +7,11 @@ package bls24317 import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() @@ -504,70 +637,201 @@ func fillBenchBasesG1(samplePoints []G1Affine) { func TestBestCG2(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG2 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG2 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG2 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG2(nbPoints); got != want { + got := bestCG2(nbPoints) + if got != want { t.Fatalf("bestCG2(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG2(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG2(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG2(nbPoints); got < prev { + t.Fatalf("bestCG2 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG2(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG2) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG2(was); got < msmBatchAffineC { + t.Fatalf("bestCG2(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG2 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG2(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/ecc/bn254/multiexp.go b/ecc/bn254/multiexp.go index 4f7c32a408..0a371d6a05 100644 --- a/ecc/bn254/multiexp.go +++ b/ecc/bn254/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -425,9 +436,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -440,14 +456,25 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG2 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost + + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -472,36 +499,31 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // bestCG2 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG2 +// and process every chunk with the wrong bucket array. // -// cost = bits/c * (nbPoints + 2^{c}) +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results -// -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -731,6 +753,92 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 10: + return 80 + case 11: + return 150 + case 12: + return 200 + case 13: + return 350 + case 14: + return 400 + case 15: + return 500 + case 16: + return 640 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/bn254/multiexp_test.go b/ecc/bn254/multiexp_test.go index 5a7d42f563..1c8e60f19f 100644 --- a/ecc/bn254/multiexp_test.go +++ b/ecc/bn254/multiexp_test.go @@ -7,9 +7,11 @@ package bn254 import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() @@ -504,70 +637,201 @@ func fillBenchBasesG1(samplePoints []G1Affine) { func TestBestCG2(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG2 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG2 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG2 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG2(nbPoints); got != want { + got := bestCG2(nbPoints) + if got != want { t.Fatalf("bestCG2(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG2(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG2(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG2(nbPoints); got < prev { + t.Fatalf("bestCG2 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG2(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG2) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG2(was); got < msmBatchAffineC { + t.Fatalf("bestCG2(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG2 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG2(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/ecc/bw6-633/multiexp.go b/ecc/bw6-633/multiexp.go index f516d20cb2..622258196c 100644 --- a/ecc/bw6-633/multiexp.go +++ b/ecc/bw6-633/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 8, 12, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -372,9 +383,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -387,14 +403,25 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG2 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost + + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -419,36 +446,31 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // bestCG2 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG2 +// and process every chunk with the wrong bucket array. // -// cost = bits/c * (nbPoints + 2^{c}) +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results -// -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 8, 12, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -625,6 +647,82 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 12: + return 200 + case 16: + return 640 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/bw6-633/multiexp_test.go b/ecc/bw6-633/multiexp_test.go index d44a7e51f7..cb61381f2d 100644 --- a/ecc/bw6-633/multiexp_test.go +++ b/ecc/bw6-633/multiexp_test.go @@ -7,9 +7,11 @@ package bw6633 import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 6, 8, 12, 16} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 8, 12, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() @@ -504,70 +637,201 @@ func fillBenchBasesG1(samplePoints []G1Affine) { func TestBestCG2(t *testing.T) { implementedCs := []uint64{4, 5, 6, 8, 12, 16} - // mirrors the cost model used by bestCG2 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG2 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG2 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG2(nbPoints); got != want { + got := bestCG2(nbPoints) + if got != want { t.Fatalf("bestCG2(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG2(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG2(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG2(nbPoints); got < prev { + t.Fatalf("bestCG2 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG2(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 8, 12, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG2) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG2(was); got < msmBatchAffineC { + t.Fatalf("bestCG2(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG2 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG2(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/ecc/bw6-761/multiexp.go b/ecc/bw6-761/multiexp.go index b18531b437..74a3068e43 100644 --- a/ecc/bw6-761/multiexp.go +++ b/ecc/bw6-761/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 8, 10, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -374,9 +385,14 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -389,14 +405,25 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG2 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost + + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG2(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -421,36 +448,31 @@ func (p *G2Jac) MultiExp(points []G2Affine, scalars []fr.Element, config ecc.Mul // bestCG2 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG2 +// and process every chunk with the wrong bucket array. // -// cost = bits/c * (nbPoints + 2^{c}) +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results -// -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG2(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 8, 10, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -629,6 +651,82 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 10: + return 80 + case 16: + return 640 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/bw6-761/multiexp_test.go b/ecc/bw6-761/multiexp_test.go index 3a8c133e51..5f0dadcb9f 100644 --- a/ecc/bw6-761/multiexp_test.go +++ b/ecc/bw6-761/multiexp_test.go @@ -7,9 +7,11 @@ package bw6761 import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 8, 10, 16} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 8, 10, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() @@ -504,70 +637,201 @@ func fillBenchBasesG1(samplePoints []G1Affine) { func TestBestCG2(t *testing.T) { implementedCs := []uint64{4, 5, 8, 10, 16} - // mirrors the cost model used by bestCG2 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG2 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG2 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG2(nbPoints); got != want { + got := bestCG2(nbPoints) + if got != want { t.Fatalf("bestCG2(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG2(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG2(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG2(nbPoints); got < prev { + t.Fatalf("bestCG2 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG2(t *testing.T) { + implementedCs := []uint64{4, 5, 8, 10, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG2) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG2(was); got < msmBatchAffineC { + t.Fatalf("bestCG2(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG2 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG2(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/ecc/grumpkin/multiexp.go b/ecc/grumpkin/multiexp.go index 0a693de4c3..2773cdd32a 100644 --- a/ecc/grumpkin/multiexp.go +++ b/ecc/grumpkin/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -387,6 +398,92 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 10: + return 80 + case 11: + return 150 + case 12: + return 200 + case 13: + return 350 + case 14: + return 400 + case 15: + return 500 + case 16: + return 640 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/grumpkin/multiexp_test.go b/ecc/grumpkin/multiexp_test.go index a7946237a5..c6e51882e9 100644 --- a/ecc/grumpkin/multiexp_test.go +++ b/ecc/grumpkin/multiexp_test.go @@ -7,9 +7,11 @@ package grumpkin import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/ecc/secp256k1/multiexp.go b/ecc/secp256k1/multiexp.go index 6cd2c42b70..7407dab98e 100644 --- a/ecc/secp256k1/multiexp.go +++ b/ecc/secp256k1/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -378,6 +389,90 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 10: + return 80 + case 11: + return 150 + case 12: + return 200 + case 13: + return 350 + case 14: + return 400 + case 15: + return 500 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/secp256k1/multiexp_test.go b/ecc/secp256k1/multiexp_test.go index 45ea32776f..53c966be82 100644 --- a/ecc/secp256k1/multiexp_test.go +++ b/ecc/secp256k1/multiexp_test.go @@ -7,9 +7,11 @@ package secp256k1 import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/ecc/secp256r1/multiexp.go b/ecc/secp256r1/multiexp.go index ef3c8c27e1..365fc60b9c 100644 --- a/ecc/secp256r1/multiexp.go +++ b/ecc/secp256r1/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -378,6 +389,90 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 10: + return 80 + case 11: + return 150 + case 12: + return 200 + case 13: + return 350 + case 14: + return 400 + case 15: + return 500 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/secp256r1/multiexp_test.go b/ecc/secp256r1/multiexp_test.go index 49e4dae52b..e4da44239c 100644 --- a/ecc/secp256r1/multiexp_test.go +++ b/ecc/secp256r1/multiexp_test.go @@ -7,9 +7,11 @@ package secp256r1 import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/ecc/stark-curve/multiexp.go b/ecc/stark-curve/multiexp.go index 4905eec7cb..e92b75e7ba 100644 --- a/ecc/stark-curve/multiexp.go +++ b/ecc/stark-curve/multiexp.go @@ -81,9 +81,14 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -96,14 +101,25 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int { return (nbPoints + int((1 << c))) } + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestCG1 minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) + + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestCG1(nbPoints / 2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit*2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit*2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -128,36 +144,31 @@ func (p *G1Jac) MultiExp(points []G1Affine, scalars []fr.Element, config ecc.Mul // bestCG1 returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessorG1 +// and process every chunk with the wrong bucket array. // -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. -// -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestCG1(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -385,6 +396,92 @@ func lastC(c uint64) uint64 { return c + 1 - nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = 10 + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + case 10: + return 80 + case 11: + return 150 + case 12: + return 200 + case 13: + return 350 + case 14: + return 400 + case 15: + return 500 + case 16: + return 640 + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 diff --git a/ecc/stark-curve/multiexp_test.go b/ecc/stark-curve/multiexp_test.go index 35af961080..822a07b3bb 100644 --- a/ecc/stark-curve/multiexp_test.go +++ b/ecc/stark-curve/multiexp_test.go @@ -7,9 +7,11 @@ package starkcurve import ( "fmt" + "math" "math/big" "math/bits" "math/rand/v2" + "reflect" "runtime" "sync" "testing" @@ -23,70 +25,201 @@ import ( func TestBestCG1(t *testing.T) { implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - // mirrors the cost model used by bestCG1 - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestCG1 for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessorG1 and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestCG1(nbPoints); got != want { + got := bestCG1(nbPoints) + if got != want { t.Fatalf("bestCG1(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestCG1(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestCG1(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestCG1(nbPoints); got < prev { + t.Fatalf("bestCG1 is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossoverG1(t *testing.T) { + implementedCs := []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestCG1) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestCG1(was); got < msmBatchAffineC { + t.Fatalf("bestCG1(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessorG1 falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExpG1(t *testing.T) { parameters := gopter.DefaultTestParameters() diff --git a/internal/generator/ecc/generate.go b/internal/generator/ecc/generate.go index 4e606feae8..a216cc8346 100644 --- a/internal/generator/ecc/generate.go +++ b/internal/generator/ecc/generate.go @@ -54,6 +54,12 @@ func Generate(conf config.Curve, baseDir string, gen *common.Generator) error { } return lc } + // batchAffineC is the smallest window size whose chunk processor accumulates + // buckets with the batch affine method; every smaller window uses extended + // jacobian formulas. The msm cost model prices the two differently, so the + // boundary has to come from one place. + const batchAffineC = 10 + batchSize := func(c int) int { switch c { case 10: @@ -76,6 +82,7 @@ func Generate(conf config.Curve, baseDir string, gen *common.Generator) error { funcs := common.Funcs() funcs["lastC"] = lastC funcs["batchSize"] = batchSize + funcs["batchAffineC"] = func() int { return batchAffineC } funcs["nbBuckets"] = func(c int) int { return 1 << (c - 1) @@ -116,6 +123,20 @@ func Generate(conf config.Curve, baseDir string, gen *common.Generator) error { lastCG2 = lastCG2[:0] } + // the window sizes whose chunk processor is the batch affine one, across both + // groups; the msm cost model needs their batch sizes to price a bucket + // accumulation on that path + funcs["batchAffineCs"] = func() []int { + cs := make([]int, 0, len(conf.G1.CRange)+len(conf.G2.CRange)) + for _, c := range append(append([]int{}, conf.G1.CRange...), conf.G2.CRange...) { + if c >= batchAffineC && !slices.Contains(cs, c) { + cs = append(cs, c) + } + } + sort.Ints(cs) + return cs + } + bavardOpts := []func(*bavard.Bavard) error{bavard.Funcs(funcs)} if err := eccGen.GenerateWithOptions(conf, packageName, "", "", bavardOpts, entries...); err != nil { return err diff --git a/internal/generator/ecc/template/multiexp.go.tmpl b/internal/generator/ecc/template/multiexp.go.tmpl index 68c309b87a..dc54cdd610 100644 --- a/internal/generator/ecc/template/multiexp.go.tmpl +++ b/internal/generator/ecc/template/multiexp.go.tmpl @@ -50,6 +50,82 @@ func lastC(c uint64) uint64 { return c+1-nbAvailableBits } +// msmBatchAffineC is the smallest window size whose chunk processor accumulates +// buckets with the batch affine method; every smaller window accumulates with +// extended jacobian formulas. It and the boundary in getChunkProcessor{G1,G2} +// are generated from the same constant, so the cost model cannot drift away +// from the processor it is pricing. +const msmBatchAffineC = {{batchAffineC}} + +// msmBatchSize returns the size of the batch the batch affine chunk processor +// accumulates at window size c, or 0 for the window sizes that accumulate with +// extended jacobian formulas. Generated from the same batch sizes as +// getChunkProcessor{G1,G2}. +func msmBatchSize(c uint64) int { + switch c { + {{- range $c := batchAffineCs}} + case {{$c}}: + return {{batchSize $c}} + {{- end}} + } + return 0 +} + +// Costs below are relative to one extended jacobian bucket addition, which is +// what the c < msmBatchAffineC chunk processor does per point. +// +// They are ratios of field operation mixes, so they drift with the curve and +// with the host: a calibration, not a derivation. Both were fitted by timing +// _innerMsm at fixed c on an Apple M2, over BLS12-381 and BN254 G1, nbPoints +// from 1024 to 32768 and c from 8 to 13, serially and again at NbTasks 8 and +// 16. The fit is flat, so the exact split between the two hardly matters: +// anything from (0.5, 14) to (0.6, 10) picks the same window at every size +// measured. Their ratio to each other is what carries the shape. +const ( + // an affine bucket addition, once the batch has been inverted + msmAffineAddCost = 0.5 + // a field inversion, which the batch affine processor pays once per batch + msmInverseCost = 14.0 +) + +// msmBucketAddCost approximates what accumulating one point into its bucket +// costs at window size c. +// +// This is the whole reason the model can tell the two chunk processors apart. +// Batch affine replaces the inversion inside every bucket addition with one +// inversion shared across a batch, so its per-point cost falls as the batch +// grows, and the batch grows with c. A single discount for every batch affine +// window would price c = 16 the same as c = 10, which is 8x the batch. +func msmBucketAddCost(c uint64) float64 { + batchSize := msmBatchSize(c) + if batchSize == 0 { + return 1.0 // extended jacobian: the unit the other costs are relative to + } + return msmAffineAddCost + msmInverseCost/float64(batchSize) +} + +// msmChunkCost approximates, in extended jacobian bucket additions, the work one +// chunk of a msm over nbPoints points costs at window size c: one bucket +// accumulation per point, then the reduction of the chunk's 2^{c-1} buckets, +// counted here as 2^{c} because reducing a bucket costs an addition and a +// doubling. +func msmChunkCost(c uint64, nbPoints int) float64 { + return msmBucketAddCost(c)*float64(nbPoints) + float64(uint64(1)< nominal weight. weight float32 @@ -289,9 +365,14 @@ func (p *{{ $.TJacobian }}) MultiExp(points []{{ $.TAffine }}, scalars []fr.Elem // splitting the msm will **add** operations, but if it allows to use more CPU, it might be worth it. // costFunction returns a metric that represent the "wall time" of the algorithm - costFunction := func(nbTasks, nbCpus, costPerTask int) int { - // cost for the reduction of all tasks (msmReduceChunk) - totalCost := nbTasks + costFunction := func(c uint64, nbTasks, nbCpus int, costPerTask float64) float64 { + // cost for the reduction of all tasks (msmReduceChunk). It walks the chunks + // back to front, doubling c times and adding once per chunk, and it runs + // sequentially, so it lands on wall time in full. Counting it in group + // operations, the same unit as costPerTask, is what makes the two terms + // addable; counting one per chunk understated it by a factor of c and made + // the comparison below turn on rounding. + totalCost := float64(nbTasks) * float64(c+1) // cost for the computation of each task (msmProcessChunk) for nbTasks >= nbCpus { @@ -304,14 +385,25 @@ func (p *{{ $.TJacobian }}) MultiExp(points []{{ $.TAffine }}, scalars []fr.Elem return totalCost } - // costPerTask is the approximate number of group ops per task - costPerTask := func(c uint64, nbPoints int) int {return (nbPoints + int((1 << c)))} + // costPerTask is the approximate number of group ops per task; one task is one + // chunk, so this is the same per-chunk model bestC{{ $.UPointName }} minimises. + // It has to be, otherwise the split probe below would compare a window chosen + // under one cost model against a cost computed under another. + costPerTask := msmChunkCost + + // how many chunks can actually run at the same time. costFunction counts a + // wave of chunks per pass over the CPUs, and splitting only pays by filling + // CPUs a single msm would leave idle, so this has to be a count of CPUs. + // config.NbTasks is not one: it defaults to twice runtime.NumCPU(), and + // handing that over as the CPU count invents parallelism that is not there, + // which makes splitting look free at sizes where it is measurably a loss. + nbCpus := min(config.NbTasks, runtime.NumCPU()) - costPreSplit := costFunction(nbChunks, config.NbTasks, costPerTask(C, nbPoints)) + costPreSplit := costFunction(C, nbChunks, nbCpus, costPerTask(C, nbPoints)) cPostSplit := bestC{{ $.UPointName }}(nbPoints/2) nbChunksPostSplit := int(computeNbChunks(cPostSplit)) - costPostSplit := costFunction(nbChunksPostSplit * 2, config.NbTasks, costPerTask(cPostSplit, nbPoints/2)) + costPostSplit := costFunction(cPostSplit, nbChunksPostSplit * 2, nbCpus, costPerTask(cPostSplit, nbPoints/2)) // if the cost of the split msm is lower than the cost of the non split msm, we split if costPostSplit < costPreSplit { @@ -336,38 +428,33 @@ func (p *{{ $.TJacobian }}) MultiExp(points []{{ $.TAffine }}, scalars []fr.Elem // bestC{{ $.UPointName }} returns the window size c to use for a msm of size nbPoints. // -// It minimises an approximate group-operation count: -// -// cost = bits/c * (nbPoints + 2^{c}) -// -// this needs to be verified empirically. -// for example, on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gives better results +// It minimises msmCost over the window sizes that have a generated chunk +// processor, so the returned c is always one of them; returning anything else +// would fall through to the default branch of getChunkProcessor{{ $.UPointName }} +// and process every chunk with the wrong bucket array. // -// When several window sizes score the same, the largest is returned. Such ties -// are exact rather than an artefact of rounding: two window sizes c1 < c2 tie at -// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1), which is 2^c1 * (c1 - 1) when c2 is -// c1 + 1. +// The model is only an approximation and still wants verifying empirically; for +// example on a MBP 2016, for G2 MultiExp > 8M points, hand picking c gave better +// results. // -// Exactly one tie per curve straddles the two chunk processors: windows c >= 10 -// use the batch-affine one, which amortises a single field inversion over a whole -// batch of bucket additions, while c <= 9 uses the extended-Jacobian one. The cost -// model counts a bucket accumulation and a bucket reduction as one group operation -// each and so cannot see that difference; at that tie the larger window is -// measurably the faster one. The remaining ties fall wholly inside one processor -// or the other, where the model has no known bias; the larger window is taken -// there too, so the rule stays uniform and because it yields fewer chunks, hence -// fewer goroutines and channels per msm. +// When several window sizes score the same, the largest is returned. Ties are +// exact rather than an artefact of rounding: within one chunk processor, where +// the point term carries the same weight at both window sizes, c1 < c2 tie at +// nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1). The model has no known bias at +// those points, so the tie goes to the larger window because it yields fewer +// chunks, hence fewer goroutines and channels per msm. A tie across the two +// chunk processors is not expected, since the batch affine bucket cost has no +// exact binary representation, and taking the larger window would be right +// anyway. func bestC{{ $.UPointName }}(nbPoints int) uint64 { // implemented msmC methods (the c we use must be in this slice) implementedCs := []uint64{ {{- range $c := $.CRange}}{{- if ge $c 4}}{{$c}},{{- end}}{{- end}} } - var C uint64 + C := implementedCs[0] min := math.MaxFloat64 for _, c := range implementedCs { - cc := (fr.Bits+1) * (nbPoints + (1 << c)) - cost := float64(cc) / float64(c) - if cost <= min { + if cost := msmCost(c, nbPoints); cost <= min { min = cost C = c } @@ -449,7 +536,7 @@ func getChunkProcessor{{ $.UPointName }}(c uint64, stat chunkStat) func(chunkID {{- end }} {{range $c := $.CRange}} case {{$c}}: - {{- if le $c 9}} + {{- if lt $c batchAffineC}} return processChunk{{ $.UPointName }}Jacobian[bucket{{ $.TJacobianExtended }}C{{$c}}] {{- else}} const batchSize = {{batchSize $c}} diff --git a/internal/generator/ecc/template/multiexp_affine.go.tmpl b/internal/generator/ecc/template/multiexp_affine.go.tmpl index e5d42eb8ab..cf42469c78 100644 --- a/internal/generator/ecc/template/multiexp_affine.go.tmpl +++ b/internal/generator/ecc/template/multiexp_affine.go.tmpl @@ -249,7 +249,7 @@ func processChunk{{ $.UPointName }}BatchAffine[BJE ib{{ $.TJacobianExtended }},B // we declare the buckets as fixed-size array types // this allow us to allocate the buckets on the stack {{- range $c := $.CRange}} -{{- if gt $c 9}} +{{- if ge $c batchAffineC}} type bucket{{ $.TAffine }}C{{$c}} [{{nbBuckets $c}}]{{ $.TAffine }} {{- end}} {{- end}} @@ -258,7 +258,7 @@ type bucket{{ $.TAffine }}C{{$c}} [{{nbBuckets $c}}]{{ $.TAffine }} // buckets: array of {{ $.TAffine }} points of size 1 << (c-1) type ib{{ $.TAffine }} interface { {{- range $i, $c := $.CRange}} - {{- if gt $c 9}} + {{- if ge $c batchAffineC}} bucket{{ $.TAffine }}C{{$c}} {{- if not (last $i $.CRange)}} | {{- end}} {{- end}} {{- end}} @@ -267,7 +267,7 @@ type ib{{ $.TAffine }} interface { // array of coordinates {{ $.CoordType }} type c{{ $.TAffine }} interface { {{- range $i, $c := $.CRange}} - {{- if gt $c 9}} + {{- if ge $c batchAffineC}} c{{ $.TAffine }}C{{$c}} {{- if not (last $i $.CRange)}} | {{- end}} {{- end}} {{- end}} @@ -276,7 +276,7 @@ type c{{ $.TAffine }} interface { // buckets: array of {{ $.TAffine }} points (for the batch addition) type p{{ $.TAffine }} interface { {{- range $i, $c := $.CRange}} - {{- if gt $c 9}} + {{- if ge $c batchAffineC}} p{{ $.TAffine }}C{{$c}} {{- if not (last $i $.CRange)}} | {{- end}} {{- end}} {{- end}} @@ -285,7 +285,7 @@ type p{{ $.TAffine }} interface { // buckets: array of *{{ $.TAffine }} points (for the batch addition) type pp{{ $.TAffine }} interface { {{- range $i, $c := $.CRange}} - {{- if gt $c 9}} + {{- if ge $c batchAffineC}} pp{{ $.TAffine }}C{{$c}} {{- if not (last $i $.CRange)}} | {{- end}} {{- end}} {{- end}} @@ -294,7 +294,7 @@ type pp{{ $.TAffine }} interface { // buckets: array of {{ $.TAffine }} queue operations (for the batch addition) type qOps{{ $.TAffine }} interface { {{- range $i, $c := $.CRange}} - {{- if gt $c 9}} + {{- if ge $c batchAffineC}} q{{ $.TAffine }}C{{$c}} {{- if not (last $i $.CRange)}} | {{- end}} {{- end}} {{- end}} @@ -302,7 +302,7 @@ type qOps{{ $.TAffine }} interface { {{- range $c := $.CRange}} -{{if gt $c 9}} +{{if ge $c batchAffineC}} // batch size {{batchSize $c}} when c = {{$c}} type c{{ $.TAffine }}C{{$c}} [{{batchSize $c}}]{{ $.CoordType }} type p{{ $.TAffine }}C{{$c}} [{{batchSize $c}}]{{ $.TAffine }} diff --git a/internal/generator/ecc/template/tests/multiexp.go.tmpl b/internal/generator/ecc/template/tests/multiexp.go.tmpl index 5fc05af9c9..ce56d5073d 100644 --- a/internal/generator/ecc/template/tests/multiexp.go.tmpl +++ b/internal/generator/ecc/template/tests/multiexp.go.tmpl @@ -9,6 +9,8 @@ import ( "fmt" + "math" + "reflect" "runtime" "math/rand/v2" "math/big" @@ -35,70 +37,205 @@ func TestBestC{{$.UPointName}}(t *testing.T) { {{- range $c := $.CRange}}{{- if ge $c 4}}{{$c}},{{- end}}{{- end}} } - // mirrors the cost model used by bestC{{$.UPointName}} - cost := func(nbPoints int, c uint64) float64 { - cc := (fr.Bits + 1) * (nbPoints + (1 << c)) - return float64(cc) / float64(c) + isImplemented := func(c uint64) bool { + for _, v := range implementedCs { + if v == c { + return true + } + } + return false } - // bestC must return the largest window size among those minimising the cost. - // See bestC{{$.UPointName}} for why ties resolve upward; the one that matters - // most is the tie crossing from the extended-Jacobian chunk processor to the - // batch-affine one, where the smaller c is measurably slower. + // bestC must return the largest window size among those minimising msmCost. + // Anything outside implementedCs would fall through to the default branch of + // getChunkProcessor{{$.UPointName}} and silently use the wrong bucket array. assertLargestMinimiser := func(nbPoints int) { t.Helper() - best := cost(nbPoints, implementedCs[0]) + best := msmCost(implementedCs[0], nbPoints) for _, c := range implementedCs[1:] { - if v := cost(nbPoints, c); v < best { + if v := msmCost(c, nbPoints); v < best { best = v } } var want uint64 for _, c := range implementedCs { - if cost(nbPoints, c) == best { + if msmCost(c, nbPoints) == best { want = c // implementedCs is ascending, so this ends on the largest } } - if got := bestC{{$.UPointName}}(nbPoints); got != want { + got := bestC{{$.UPointName}}(nbPoints) + if got != want { t.Fatalf("bestC{{$.UPointName}}(%d) = %d, want %d (largest cost-minimising window)", nbPoints, got, want) } + if !isImplemented(got) { + t.Fatalf("bestC{{$.UPointName}}(%d) = %d, which has no generated chunk processor", nbPoints, got) + } } sweep := 1 << 16 if testing.Short() { sweep = 1 << 12 } - for nbPoints := 1; nbPoints <= sweep; nbPoints++ { + prev := bestC{{$.UPointName}}(0) + for nbPoints := 0; nbPoints <= sweep; nbPoints++ { assertLargestMinimiser(nbPoints) + // each cost line has a smaller slope and a larger intercept than the one + // before it, so the window size can only grow with nbPoints + if got := bestC{{$.UPointName}}(nbPoints); got < prev { + t.Fatalf("bestC{{$.UPointName}} is not monotonic: %d at nbPoints=%d after %d", got, nbPoints, prev) + } else { + prev = got + } } - // exercise the exact tie points, including any beyond the sweep range. - // for consecutive window sizes c1 < c2 the model is indifferent at - // nbPoints = (c1*2^c2 - c2*2^c1) / (c2 - c1) + // exercise the exact tie points, including any beyond the sweep range. Two + // window sizes tie where their cost lines cross; the slope and intercept are + // read back out of msmCost so this does not restate the model. nbTies := 0 for i := 0; i+1 < len(implementedCs); i++ { c1, c2 := implementedCs[i], implementedCs[i+1] - num := int(c1)*(1<= 1 { + t.Fatalf("msmBucketAddCost(%d) = %v, want below the extended jacobian unit", c, got) + } + } +} + +func TestBestCBatchAffineCrossover{{$.UPointName}}(t *testing.T) { + implementedCs := []uint64{ + {{- range $c := $.CRange}}{{- if ge $c 4}}{{$c}},{{- end}}{{- end}} + } + if implementedCs[len(implementedCs)-1] < msmBatchAffineC { + t.Skip("no batch affine window is generated for this curve") + } + + // the model as it shipped before the weight was introduced + unweighted := func(nbPoints int) uint64 { + best, C := math.MaxFloat64, implementedCs[0] + for _, c := range implementedCs { + cost := float64(fr.Bits+1) * (float64(nbPoints) + float64(uint64(1)< 1<<30 { + t.Fatal("no batch affine window is ever selected") + } + } + for lo < hi { + mid := lo + (hi-lo)/2 + if f(mid) < msmBatchAffineC { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } + + was := firstBatchAffine(unweighted) + now := firstBatchAffine(bestC{{$.UPointName}}) + if now >= was { + t.Fatalf("batch affine crossover is at nbPoints=%d, no earlier than the unweighted model's %d", now, was) + } + if got := bestC{{$.UPointName}}(was); got < msmBatchAffineC { + t.Fatalf("bestC{{$.UPointName}}(%d) = %d, want a batch affine window", was, got) + } + + // bestC runs before partitionScalars, so it has to assume the batch affine + // processor is actually taken. getChunkProcessor{{$.UPointName}} falls back to + // extended jacobian when a chunk fills fewer than batchSize of its 2^{c-1} + // buckets, so the crossover must sit well above that threshold for the + // assumption to hold on non-degenerate scalars. + nbBuckets := 1 << (msmBatchAffineC - 1) + if now < 2*nbBuckets { + t.Fatalf("batch affine crossover at nbPoints=%d is too close to the %d buckets of a c=%d window", now, nbBuckets, msmBatchAffineC) + } +} + func TestMultiExp{{$.UPointName}}(t *testing.T) { parameters := gopter.DefaultTestParameters()