Skip to content

Commit e9cd8ec

Browse files
Benchmark plot script (ZcashFoundation#356)
* add first version of benchmark post * add benchmarks table * document plot.py * Mention cargo-criterion installation in plot.py pydoc --------- Co-authored-by: Conrado Gouvea <[email protected]>
1 parent 2668555 commit e9cd8ec

10 files changed

+354
-0
lines changed

performance.md

+183
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# FROST Performance
2+
3+
4+
## What is FROST?
5+
6+
FROST is a threshold Schnorr signature scheme
7+
[invented](https://eprint.iacr.org/2020/852) by Chelsea Komlo (researcher at the
8+
Zcash Foundation) and Ian Goldberg, and in the process of becoming an [IETF
9+
RFC](https://datatracker.ietf.org/doc/draft-irtf-cfrg-frost/). Threshold
10+
signatures allows a private key being split into shares given to multiple
11+
participants, allowing a subgroup of them (e.g. 3 out of 5, or whatever
12+
threshold specified at key generation) to generate a signature that can be
13+
verified by the group public key, as if it were signed by the original unsplit
14+
private key. It has many applications such as allowing multiple entities to
15+
manage a cryptocurrency wallet in a safer and more resilient manner.
16+
17+
Currently, the RFC is close to completion, and we also completed [a Rust
18+
implementation](https://github.com/ZcashFoundation/frost/) of all ciphersuites
19+
specified in the RFC, and are now doing final cleanups and improvements prior to
20+
the first release of the crates (which will soon be audited).
21+
22+
23+
## Benchmarking and investigating the Aggregate step
24+
25+
When we presented FROST at Zcon 3, we were asked how FROST performed in larger
26+
settings, such as a 667-of-1000 signers. (This is motivated by a mechanism
27+
proposed by Christopher Goes for [bridging Zcash with other ecosystems using
28+
FROST](https://forum.zcashcommunity.com/t/proposed-architecture-for-a-zcash-namada-ibc-ecosystem-ethereum-ecosystem-non-custodial-bridge-using-frost-multisignatures/42749).)
29+
We set out to benchmark our Rust implementation, and I was a bit surprised about
30+
one particular step, “Aggregate”.
31+
32+
The FROST scheme can be split into steps. The first one is Key Generation, which
33+
only needs to be done once, while the rest are carried out each time the group
34+
wishes to generate a new signature. In Round 1, the participant generates
35+
commitments which are broadcast to all other participants via a Coordinator. In
36+
Round 2, using these commitments and their respective key shares generated
37+
during Key Generation, they produce a signature share which is sent to the
38+
Coordinator. Finally, the Coordinator carries out the final step, Aggregate,
39+
which produces the final signatures from all the signatures shares received.
40+
41+
The benchmark for the Ristretto255 suite looked like the following. (Benchmarks
42+
were run on an AMD Ryzen 9 5900X 3.7GHZ, Ubuntu 22.04, Rust 1.66.0.)
43+
44+
![](times-by-size-and-function-ristretto255-all-shares.png)
45+
46+
(Note that Round 1 and 2 timings in this post refer to per-signer timings, while
47+
Key Generation and Aggregate are performed by the Coordinator.)
48+
49+
It was expected that the timings would increase with the larger number of
50+
participants (with the exception of Round 1, which does not depend on that
51+
number), but the Aggregate timings appeared too high, surpassing 400ms for the
52+
667-of-1000 case (which may not seem much but it’s unusual for a signing
53+
procedure).
54+
55+
I intended to investigate this but I didn’t even need to. Coincidentally, while
56+
the RFC was in the last call for feedback, Tim Ruffing [pointed
57+
out](https://mailarchive.ietf.org/arch/msg/cfrg/QQhyjvvcoaqLslaX3gWwABqHN-s/)
58+
that Aggregate can be sped up significantly. Originally, it was specified that
59+
each share received from the participants should be verified (each signature
60+
share can be verified separately to ensure it is correct) and then aggregated.
61+
Tim’s observation is that the shares can be simply aggregated and the final
62+
signature verified with the group public key. If the verification fails, then
63+
it’s possible to find which participant generated an incorrect share by
64+
verifying them one by one (if desired). This greatly speeds up the case where
65+
all shares are correct, which should be the most common.
66+
67+
This is how the Ristretto255 timings look like with that optimization
68+
implemented:
69+
70+
![](times-by-size-and-function-ristretto255-aggregated.png)
71+
72+
Now the Aggregate performance is very similar to the Round 2 step, which makes
73+
sense since they have a very similar structure.
74+
75+
Here’s the Aggregate performance comparison for all ciphersuites, in three
76+
different scenarios:
77+
78+
79+
![](verify-aggregated-vs-all-shares-10.png)
80+
81+
![](verify-aggregated-vs-all-shares-100.png)
82+
83+
![](verify-aggregated-vs-all-shares-1000.png)
84+
85+
86+
## Examining overall performance
87+
88+
With the benchmark machinery in place (we used
89+
[criterion.rs](https://github.com/bheisler/criterion.rs)) we can provide
90+
benchmark results for all supported ciphersuites in different scenarios. These
91+
all use the optimization described above.
92+
93+
![](times-by-ciphersuite-and-function-10.png)
94+
95+
![](times-by-ciphersuite-and-function-100.png)
96+
97+
![](times-by-ciphersuite-and-function-1000.png)
98+
99+
The same data in table format:
100+
101+
<!-- Benchmarks -->
102+
### ed448
103+
104+
| | Key Generation with Dealer | Round 1 | Round 2 | Aggregate |
105+
| :---------- | -------------------------: | ------: | ------: | --------: |
106+
| 2-of-3 | 1.56 | 0.51 | 0.75 | 1.39 |
107+
| 7-of-10 | 4.65 | 0.53 | 2.36 | 2.88 |
108+
| 67-of-100 | 46.05 | 0.52 | 21.04 | 20.37 |
109+
| 667-of-1000 | 693.45 | 0.53 | 211.68 | 197.00 |
110+
111+
### ristretto255
112+
113+
| | Key Generation with Dealer | Round 1 | Round 2 | Aggregate |
114+
| :---------- | -------------------------: | ------: | ------: | --------: |
115+
| 2-of-3 | 0.24 | 0.08 | 0.13 | 0.22 |
116+
| 7-of-10 | 0.71 | 0.08 | 0.42 | 0.47 |
117+
| 67-of-100 | 7.61 | 0.08 | 3.77 | 3.40 |
118+
| 667-of-1000 | 179.43 | 0.08 | 38.32 | 32.54 |
119+
120+
### p256
121+
122+
| | Key Generation with Dealer | Round 1 | Round 2 | Aggregate |
123+
| :---------- | -------------------------: | ------: | ------: | --------: |
124+
| 2-of-3 | 0.56 | 0.18 | 0.33 | 0.58 |
125+
| 7-of-10 | 1.71 | 0.19 | 1.08 | 1.24 |
126+
| 67-of-100 | 16.51 | 0.18 | 10.03 | 9.38 |
127+
| 667-of-1000 | 206.85 | 0.19 | 97.49 | 90.82 |
128+
129+
### secp256k1
130+
131+
| | Key Generation with Dealer | Round 1 | Round 2 | Aggregate |
132+
| :---------- | -------------------------: | ------: | ------: | --------: |
133+
| 2-of-3 | 0.26 | 0.09 | 0.15 | 0.25 |
134+
| 7-of-10 | 0.78 | 0.09 | 0.48 | 0.52 |
135+
| 67-of-100 | 7.50 | 0.09 | 4.41 | 3.82 |
136+
| 667-of-1000 | 123.74 | 0.09 | 46.11 | 37.48 |
137+
138+
### ed25519
139+
140+
| | Key Generation with Dealer | Round 1 | Round 2 | Aggregate |
141+
| :---------- | -------------------------: | ------: | ------: | --------: |
142+
| 2-of-3 | 0.24 | 0.08 | 0.12 | 0.22 |
143+
| 7-of-10 | 0.73 | 0.08 | 0.39 | 0.45 |
144+
| 67-of-100 | 7.70 | 0.08 | 3.64 | 3.28 |
145+
| 667-of-1000 | 181.45 | 0.08 | 36.92 | 31.88 |
146+
147+
148+
<!-- Benchmarks -->
149+
150+
The time-consuming part of each step is elliptic curve point multiplication.
151+
Here’s a breakdown:
152+
153+
- Key Generation with Trusted Dealer:
154+
155+
- One base point multiplication to derive the group public key from the group
156+
private key;
157+
- One base point multiplication per MIN_PARTICIPANTS to derive a commitment
158+
for each polynomial coefficient;
159+
- One base point multiplication per MAX_PARTICIPANTS to derive their
160+
individual public keys.
161+
162+
- Round 1:
163+
164+
- Two base point multiplications to generate commitments to the pair of
165+
nonces.
166+
167+
- Round 2:
168+
169+
- One point multiplication per NUM_PARTICIPANTS to compute the group
170+
commitment.
171+
172+
- Aggregate:
173+
174+
- One point multiplication per NUM_PARTICIPANTS to compute the group
175+
commitment. If the Coordinator is also a participant, they could reuse the
176+
value from Round 2, but we didn’t assume that in our benchmark (and our
177+
implementation does not support this for now);
178+
- One base point multiplication and one general point multiplication to verify
179+
the aggregated signature;
180+
- Verifying all shares (i.e. in our original approach, or to find a corrupt
181+
signer if the aggregated signature failed) additionally requires one base
182+
point multiplication and two general point multiplications per
183+
NUM_PARTICIPANTS to actually verify the share.

plot.py

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""
2+
3+
Generate the graphs for the FROST perfomance blog post.
4+
5+
Install cargo-criterion:
6+
7+
cargo install cargo-criterion
8+
9+
Run the benchmarks with:
10+
11+
(check out old code)
12+
13+
cargo criterion --message-format=json 'FROST' | tee > benchmark-verify-all-shares.txt
14+
15+
(check out new code)
16+
17+
cargo criterion --message-format=json 'FROST' | tee > benchmark-verify-aggregate.txt
18+
19+
And then run:
20+
21+
python3 plot.py
22+
23+
It will generate the figures (names are partially hardcoded in each functions)
24+
and will insert/update the tables inside `performance.md`
25+
"""
26+
27+
import matplotlib.pyplot as plt
28+
import numpy as np
29+
import json
30+
31+
32+
def load_data(filename):
33+
ciphersuite_lst = []
34+
fn_lst = []
35+
size_lst = []
36+
data = {}
37+
with open(filename, 'r') as f:
38+
for line in f:
39+
line_data = json.loads(line)
40+
if line_data['reason'] == 'benchmark-complete':
41+
ciphersuite, fn, size = line_data['id'].split('/')
42+
ciphersuite = ciphersuite.replace('FROST Signing ', '')
43+
size = int(size)
44+
unit = line_data['typical']['unit']
45+
time = line_data['typical']['estimate']
46+
assert unit == 'ns'
47+
if unit == 'ns':
48+
time = time / 1e6
49+
if ciphersuite not in ciphersuite_lst:
50+
ciphersuite_lst.append(ciphersuite)
51+
if fn not in fn_lst:
52+
fn_lst.append(fn)
53+
if size in (2, 7, 67, 667):
54+
size = {2: 3, 7: 10, 67: 100, 667: 1000}[size]
55+
if size not in size_lst:
56+
size_lst.append(size)
57+
data.setdefault(ciphersuite, {}).setdefault(fn, {})[size] = time
58+
return ciphersuite_lst, fn_lst, size_lst, data
59+
60+
61+
def plot(title, filename, get_group_value, group_lst, series_lst, fmt, figsize):
62+
x = np.arange(len(group_lst)) # the label locations
63+
total_width = 0.8
64+
bar_width = total_width / len(series_lst) # the width of the bars
65+
66+
fig, ax = plt.subplots(figsize=figsize)
67+
68+
offsets = [-total_width / 2 + bar_width / 2 + (bar_width * i) for i in range(len(series_lst))]
69+
rect_lst = []
70+
for series_idx, series in enumerate(series_lst):
71+
values = [get_group_value(series_idx, series, group_idx, group) for group_idx, group in enumerate(group_lst)]
72+
rect = ax.bar(x + offsets[series_idx], values, bar_width, label=series)
73+
rect_lst.append(rect)
74+
75+
# Add some text for labels, title and custom x-axis tick labels, etc.
76+
ax.set_ylabel('Time (ms)')
77+
ax.set_title(title)
78+
ax.set_xticks(x, group_lst)
79+
ax.legend()
80+
81+
for rect in rect_lst:
82+
ax.bar_label(rect, padding=3, fmt=fmt)
83+
84+
fig.tight_layout()
85+
86+
plt.savefig(filename)
87+
plt.close()
88+
89+
90+
def times_by_size_and_function(data, ciphersuite, fn_lst, size_lst, fmt, suffix):
91+
group_lst = [str(int((size * 2 + 2) / 3)) + "-of-" + str(size) for size in size_lst]
92+
series_lst = fn_lst
93+
title = f'Times by number of signers and functions; {ciphersuite} ciphersuite'
94+
filename = f'times-by-size-and-function-{ciphersuite}-{suffix}.png'
95+
96+
def get_group_value(series_idx, series, group_idx, group):
97+
return data[ciphersuite][series][size_lst[group_idx]]
98+
99+
plot(title, filename, get_group_value, group_lst, series_lst, fmt, (8, 6))
100+
101+
102+
def times_by_ciphersuite_and_function(data, ciphersuite_lst, fn_lst, size, fmt):
103+
ciphersuite_lst = ciphersuite_lst.copy()
104+
ciphersuite_lst.sort(key=lambda cs: data[cs]['Aggregate'][size])
105+
group_lst = fn_lst
106+
series_lst = ciphersuite_lst
107+
min_signers = int((size * 2 + 2) / 3)
108+
title = f'Times by ciphersuite and function; {min_signers}-of-{size}'
109+
filename = f'times-by-ciphersuite-and-function-{size}.png'
110+
111+
def get_group_value(series_idx, series, group_idx, group):
112+
return data[series][group][size]
113+
114+
plot(title, filename, get_group_value, group_lst, series_lst, fmt, (12, 6))
115+
116+
117+
def verify_aggregated_vs_all_shares(data_aggregated, data_all_shares, ciphersuite_lst, size, fmt):
118+
ciphersuite_lst = ciphersuite_lst.copy()
119+
ciphersuite_lst.sort(key=lambda cs: data_aggregated[cs]['Aggregate'][size])
120+
group_lst = ciphersuite_lst
121+
series_lst = ['Verify all shares', 'Verify aggregated']
122+
min_signers = int((size * 2 + 2) / 3)
123+
title = f'Time comparison for Aggregate function; {min_signers}-of-{size}'
124+
filename = f'verify-aggregated-vs-all-shares-{size}.png'
125+
126+
def get_group_value(series_idx, series, group_idx, group):
127+
data = [data_all_shares, data_aggregated][series_idx]
128+
return data[group]['Aggregate'][size]
129+
130+
plot(title, filename, get_group_value, group_lst, series_lst, fmt, (8, 6))
131+
132+
133+
def generate_table(f, data, ciphersuite_lst, fn_lst, size_lst):
134+
for ciphersuite in ciphersuite_lst:
135+
print(f'### {ciphersuite}\n', file=f)
136+
print('|' + '|'.join([''] + fn_lst) + '|', file=f)
137+
print('|' + '|'.join([':---'] + ['---:'] * len(fn_lst)) + '|', file=f)
138+
for size in size_lst:
139+
min_signers = int((size * 2 + 2) / 3)
140+
print('|' + '|'.join([f'{min_signers}-of-{size}'] + ['{:.2f}'.format(data[ciphersuite][fn][size]) for fn in fn_lst]) + '|', file=f)
141+
print('', file=f)
142+
print('', file=f)
143+
144+
145+
if __name__ == '__main__':
146+
ciphersuite_lst, fn_lst, size_lst, data_aggregated = load_data('benchmark-verify-aggregate.txt')
147+
_, _, _, data_all_shares = load_data('benchmark-verify-all-shares.txt')
148+
149+
import io
150+
import re
151+
with io.StringIO() as f:
152+
generate_table(f, data_aggregated, ciphersuite_lst, fn_lst, size_lst)
153+
f.seek(0)
154+
table = f.read()
155+
with open('performance.md') as f:
156+
md = f.read()
157+
md = re.sub('<!-- Benchmarks -->[^<]*<!-- Benchmarks -->', '<!-- Benchmarks -->\n' + table + '<!-- Benchmarks -->', md, count=1, flags=re.DOTALL)
158+
with open('performance.md', 'w') as f:
159+
f.write(md)
160+
161+
size_lst = [10, 100, 1000]
162+
times_by_size_and_function(data_all_shares, 'ristretto255', fn_lst, size_lst, '%.2f', 'all-shares')
163+
times_by_size_and_function(data_aggregated, 'ristretto255', fn_lst, size_lst, '%.2f', 'aggregated')
164+
165+
times_by_ciphersuite_and_function(data_aggregated, ciphersuite_lst, fn_lst, 10, '%.2f')
166+
times_by_ciphersuite_and_function(data_aggregated, ciphersuite_lst, fn_lst, 100, '%.1f')
167+
times_by_ciphersuite_and_function(data_aggregated, ciphersuite_lst, fn_lst, 1000, '%.0f')
168+
169+
verify_aggregated_vs_all_shares(data_aggregated, data_all_shares, ciphersuite_lst, 10, '%.2f')
170+
verify_aggregated_vs_all_shares(data_aggregated, data_all_shares, ciphersuite_lst, 100, '%.1f')
171+
verify_aggregated_vs_all_shares(data_aggregated, data_all_shares, ciphersuite_lst, 1000, '%.0f')
36 KB
Loading
35.4 KB
Loading
37.9 KB
Loading
Loading
Loading
29.7 KB
Loading
30.8 KB
Loading
34.8 KB
Loading

0 commit comments

Comments
 (0)