Aggregation Performance

Analysis of post-quantum signature aggregation and verification in Lean Consensus clients.

This notebook examines:

  • Signature aggregation time
  • Total signatures aggregated
  • Aggregation verification time
  • Aggregation and verification throughput (per second and per slot)
  • Performance comparison across clients
Show code
import json
from pathlib import Path

import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots

# Set default renderer for static HTML output
import plotly.io as pio
pio.renderers.default = "notebook"
Show code
# Resolve devnet_id
DATA_DIR = Path("../data")

if devnet_id is None:
    # Use latest devnet from manifest
    devnets_path = DATA_DIR / "devnets.json"
    if devnets_path.exists():
        with open(devnets_path) as f:
            devnets = json.load(f).get("devnets", [])
        if devnets:
            devnet_id = devnets[-1]["id"]  # Latest
            print(f"Using latest devnet: {devnet_id}")
    else:
        raise ValueError("No devnets.json found. Run 'just detect-devnets' first.")

DEVNET_DIR = DATA_DIR / devnet_id
print(f"Loading data from: {DEVNET_DIR}")
Loading data from: ../data/pqdevnet-20260517T0939Z
Show code
# Load devnet metadata
with open(DATA_DIR / "devnets.json") as f:
    devnets_data = json.load(f)
    devnet_info = next((d for d in devnets_data["devnets"] if d["id"] == devnet_id), None)

if devnet_info:
    print(f"Devnet: {devnet_info['id']}")
    print(f"Duration: {devnet_info['duration_hours']:.1f} hours")
    print(f"Time: {devnet_info['start_time']} to {devnet_info['end_time']}")
    print(f"Slots: {devnet_info['start_slot']} β†’ {devnet_info['end_slot']}")
    print(f"Clients: {', '.join(devnet_info['clients'])}")
Devnet: pqdevnet-20260517T0939Z
Duration: 1.1 hours
Time: 2026-05-17T09:39:21+00:00 to 2026-05-17T10:42:04+00:00
Slots: 0 β†’ 3717
Clients: ethlambda_0, ethlambda_1, ethlambda_10, ethlambda_11, ethlambda_12, ethlambda_13, ethlambda_14, ethlambda_15, ethlambda_2, ethlambda_3, ethlambda_4, ethlambda_5, ethlambda_6, ethlambda_7, ethlambda_8, ethlambda_9, gean_0, gean_1, gean_2, gean_3, gean_4, gean_5, gean_6, gean_7, grandine_0, grandine_1, grandine_2, grandine_3, grandine_4, grandine_5, grandine_6, grandine_7, nlean_0, nlean_1, nlean_2, nlean_3, nlean_4, nlean_5, nlean_6, nlean_7, ream_0, ream_1, ream_2, ream_3, ream_4, ream_5, ream_6, ream_7, zeam_0, zeam_1, zeam_10, zeam_11, zeam_12, zeam_13, zeam_14, zeam_15, zeam_2, zeam_3, zeam_4, zeam_5, zeam_6, zeam_7, zeam_8, zeam_9

Load DataΒΆ

Show code
# Load PQ signature timing data
timing_df = pd.read_parquet(DEVNET_DIR / "pq_signature_timing.parquet")
print(f"Loaded {len(timing_df)} timing records")
print(f"Metrics: {timing_df['metric'].unique().tolist()}")
print(f"Clients: {timing_df['client'].unique().tolist()}")
Loaded 2586 timing records
Metrics: ['signing', 'verification', 'agg_verification']
Clients: ['ethlambda_1', 'ethlambda_9', 'ethlambda_4', 'ethlambda_10', 'ethlambda_13', 'ethlambda_8', 'ethlambda_0', 'ethlambda_11', 'ethlambda_5', 'ethlambda_12', 'ethlambda_15', 'ethlambda_7', 'ethlambda_2', 'ethlambda_3', 'ethlambda_6', 'ethlambda_14', 'gean_7', 'gean_1', 'gean_0', 'gean_2', 'gean_6', 'gean_4', 'gean_5', 'gean_3', 'grandine_4', 'grandine_7', 'grandine_0', 'grandine_6', 'grandine_3', 'grandine_1', 'grandine_5', 'grandine_2', 'nlean_6', 'nlean_1', 'nlean_7', 'nlean_4', 'nlean_0', 'nlean_3', 'nlean_2', 'nlean_5', 'ream_7', 'ream_2', 'ream_5', 'zeam_4', 'zeam_12', 'zeam_7', 'zeam_13', 'zeam_2', 'zeam_10', 'zeam_0', 'zeam_8', 'zeam_14', 'zeam_15', 'zeam_1', 'zeam_9', 'zeam_11', 'zeam_5', 'zeam_6', 'zeam_3', 'ream_0', 'ream_1', 'ream_4', 'ream_6', 'ream_3']
Show code
# Load PQ signature counts
counts_df = pd.read_parquet(DEVNET_DIR / "pq_signature_metrics.parquet")
print(f"Loaded {len(counts_df)} count records")
print(f"Metrics: {counts_df['metric'].unique().tolist()}")

# Unified client list from devnet metadata (includes all containers via cAdvisor)
all_clients = sorted(devnet_info["clients"])
n_cols = min(len(all_clients), 2)
n_rows = -(-len(all_clients) // n_cols)
print(f"\nAll clients ({len(all_clients)}): {all_clients}")
Loaded 4984 count records
Metrics: ['lean_pq_sig_aggregated_signatures_valid_total', 'lean_pq_sig_aggregated_signatures_invalid_total', 'lean_pq_sig_aggregated_signatures_total', 'lean_pq_sig_attestations_in_aggregated_signatures_total']

All clients (64): ['ethlambda_0', 'ethlambda_1', 'ethlambda_10', 'ethlambda_11', 'ethlambda_12', 'ethlambda_13', 'ethlambda_14', 'ethlambda_15', 'ethlambda_2', 'ethlambda_3', 'ethlambda_4', 'ethlambda_5', 'ethlambda_6', 'ethlambda_7', 'ethlambda_8', 'ethlambda_9', 'gean_0', 'gean_1', 'gean_2', 'gean_3', 'gean_4', 'gean_5', 'gean_6', 'gean_7', 'grandine_0', 'grandine_1', 'grandine_2', 'grandine_3', 'grandine_4', 'grandine_5', 'grandine_6', 'grandine_7', 'nlean_0', 'nlean_1', 'nlean_2', 'nlean_3', 'nlean_4', 'nlean_5', 'nlean_6', 'nlean_7', 'ream_0', 'ream_1', 'ream_2', 'ream_3', 'ream_4', 'ream_5', 'ream_6', 'ream_7', 'zeam_0', 'zeam_1', 'zeam_10', 'zeam_11', 'zeam_12', 'zeam_13', 'zeam_14', 'zeam_15', 'zeam_2', 'zeam_3', 'zeam_4', 'zeam_5', 'zeam_6', 'zeam_7', 'zeam_8', 'zeam_9']

Total Signatures AggregatedΒΆ

Cumulative number of individual signatures included in aggregated signature proofs over time.

Show code
att_in_agg_df = counts_df[counts_df["metric"] == "lean_pq_sig_attestations_in_aggregated_signatures_total"]

if att_in_agg_df.empty:
    print("No signatures in aggregated signature proof available")
else:
    fig = make_subplots(
        rows=n_rows, cols=n_cols,
        subplot_titles=all_clients,
        vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    for i, client in enumerate(all_clients):
        row = i // n_cols + 1
        col = i % n_cols + 1
        cdf = att_in_agg_df[att_in_agg_df["client"] == client].sort_values("timestamp")
        if not cdf.empty and cdf["value"].max() > 0:
            fig.add_trace(
                go.Scatter(
                    x=cdf["timestamp"], y=cdf["value"],
                    showlegend=False,
                    line=dict(color="#636EFA"),
                ),
                row=row, col=col,
            )
            fig.update_yaxes(title_text="count", row=row, col=col)
        else:
            fig.add_trace(
                go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
                row=row, col=col,
            )
            _n = (row - 1) * n_cols + col
            _s = "" if _n == 1 else str(_n)
            fig.add_annotation(
                text="No data available",
                xref=f"x{_s} domain", yref=f"y{_s} domain",
                x=0.5, y=0.5,
                showarrow=False,
                font=dict(size=12, color="#999"),
            )

    fig.update_layout(
        title="Total Signatures Aggregated by Client",
        height=270 * n_rows,
    )
    fig.show()

Signature Aggregation TimeΒΆ

Time to build an aggregated signatures proof from individual post-quantum signatures.

Show code
# Filter to aggregate building metric
agg_build_df = timing_df[timing_df["metric"] == "agg_building"].copy()

if agg_build_df.empty:
    print("No signature aggregation timing data available")
else:
    agg_build_df["value_ms"] = agg_build_df["value"] * 1000

    fig = make_subplots(
        rows=n_rows, cols=n_cols,
        subplot_titles=all_clients,
        vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    colors = {"0.5": "#636EFA", "0.95": "#EF553B", "0.99": "#00CC96"}
    legend_added = set()

    for i, client in enumerate(all_clients):
        row = i // n_cols + 1
        col = i % n_cols + 1
        cdf = agg_build_df[agg_build_df["client"] == client]
        if not cdf.empty:
            for q in sorted(cdf["quantile"].unique()):
                qdf = cdf[cdf["quantile"] == q].sort_values("timestamp")
                q_str = str(q)
                label = f"p{int(q * 100)}"
                show_legend = q_str not in legend_added
                legend_added.add(q_str)
                fig.add_trace(
                    go.Scatter(
                        x=qdf["timestamp"], y=qdf["value_ms"],
                        name=label, legendgroup=q_str,
                        showlegend=show_legend,
                        line=dict(color=colors.get(q_str, "#AB63FA")),
                    ),
                    row=row, col=col,
                )
        else:
            fig.add_trace(
                go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
                row=row, col=col,
            )
            _n = (row - 1) * n_cols + col
            _s = "" if _n == 1 else str(_n)
            fig.add_annotation(
                text="No data available",
                xref=f"x{_s} domain", yref=f"y{_s} domain",
                x=0.5, y=0.5,
                showarrow=False,
                font=dict(size=12, color="#999"),
            )
        fig.update_yaxes(title_text="ms", row=row, col=col)

    fig.update_layout(
        title="Signature Aggregation Time by Client",
        height=270 * n_rows,
    )
    fig.show()
No signature aggregation timing data available
Show code
# Summary statistics by client
if not agg_build_df.empty:
    summary = agg_build_df.groupby(["client", "quantile"])["value_ms"].agg(["mean", "min", "max"]).round(3)
    summary.columns = ["Mean (ms)", "Min (ms)", "Max (ms)"]
    display(summary)

Aggregation Verification TimeΒΆ

Time to verify an aggregated post-quantum signatures proof.

Show code
# Filter to aggregate verification metric
agg_ver_df = timing_df[timing_df["metric"] == "agg_verification"].copy()

if agg_ver_df.empty:
    print("No aggregated signature verification timing data available")
else:
    agg_ver_df["value_ms"] = agg_ver_df["value"] * 1000

    fig = make_subplots(
        rows=n_rows, cols=n_cols,
        subplot_titles=all_clients,
        vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    colors = {"0.5": "#636EFA", "0.95": "#EF553B", "0.99": "#00CC96"}
    legend_added = set()

    for i, client in enumerate(all_clients):
        row = i // n_cols + 1
        col = i % n_cols + 1
        cdf = agg_ver_df[agg_ver_df["client"] == client]
        if not cdf.empty:
            for q in sorted(cdf["quantile"].unique()):
                qdf = cdf[cdf["quantile"] == q].sort_values("timestamp")
                q_str = str(q)
                label = f"p{int(q * 100)}"
                show_legend = q_str not in legend_added
                legend_added.add(q_str)
                fig.add_trace(
                    go.Scatter(
                        x=qdf["timestamp"], y=qdf["value_ms"],
                        name=label, legendgroup=q_str,
                        showlegend=show_legend,
                        line=dict(color=colors.get(q_str, "#AB63FA")),
                    ),
                    row=row, col=col,
                )
        else:
            fig.add_trace(
                go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
                row=row, col=col,
            )
            _n = (row - 1) * n_cols + col
            _s = "" if _n == 1 else str(_n)
            fig.add_annotation(
                text="No data available",
                xref=f"x{_s} domain", yref=f"y{_s} domain",
                x=0.5, y=0.5,
                showarrow=False,
                font=dict(size=12, color="#999"),
            )
        fig.update_yaxes(title_text="ms", row=row, col=col)

    fig.update_layout(
        title="Aggregation Verification Time by Client",
        height=270 * n_rows,
    )
    fig.show()
Show code
# Summary statistics by client
if not agg_ver_df.empty:
    summary = agg_ver_df.groupby(["client", "quantile"])["value_ms"].agg(["mean", "min", "max"]).round(3)
    summary.columns = ["Mean (ms)", "Min (ms)", "Max (ms)"]
    display(summary)
Mean (ms) Min (ms) Max (ms)
client quantile
ethlambda_0 0.50 51.247 50.034 53.125
0.95 101.683 95.064 122.500
0.99 161.234 99.067 224.500
ethlambda_1 0.50 107.794 67.305 140.669
0.95 294.926 220.830 471.396
... ... ... ... ...
zeam_8 0.95 95.069 95.000 95.208
0.99 99.072 99.000 99.217
zeam_9 0.50 50.000 50.000 50.000
0.95 95.000 95.000 95.000
0.99 99.000 99.000 99.000

192 rows Γ— 3 columns

Signatures Aggregated per SlotΒΆ

Rate of signature aggregations per slot (4-second slot time). Useful for researchers and implementers with a focus on signature aggregation in conjunction with consensus.

Show code
SLOT_TIME = 4  # seconds

agg_total_df = counts_df[counts_df["metric"] == "lean_pq_sig_aggregated_signatures_total"]

if agg_total_df.empty:
    print("No signature aggregation count data available")
else:
    fig = make_subplots(
        rows=n_rows, cols=n_cols,
        subplot_titles=all_clients,
        vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    for i, client in enumerate(all_clients):
        row = i // n_cols + 1
        col = i % n_cols + 1
        cdf = agg_total_df[agg_total_df["client"] == client].sort_values("timestamp")
        if not cdf.empty and cdf["value"].max() > 0:
            dt = cdf["timestamp"].diff().dt.total_seconds()
            dv = cdf["value"].diff()
            rate = (dv / dt * SLOT_TIME).iloc[1:]
            ts = cdf["timestamp"].iloc[1:]
            mask = rate >= 0
            rate = rate[mask]
            ts = ts[mask]
            if not rate.empty:
                fig.add_trace(
                    go.Scatter(
                        x=ts, y=rate,
                        showlegend=False,
                        line=dict(color="#636EFA"),
                    ),
                    row=row, col=col,
                )
                fig.update_yaxes(title_text="/slot", row=row, col=col)
                continue
        fig.add_trace(
            go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
            row=row, col=col,
        )
        _n = (row - 1) * n_cols + col
        _s = "" if _n == 1 else str(_n)
        fig.add_annotation(
            text="No data available",
            xref=f"x{_s} domain", yref=f"y{_s} domain",
            x=0.5, y=0.5,
            showarrow=False,
            font=dict(size=12, color="#999"),
        )

    fig.update_layout(
        title="Signatures Aggregated per Slot by Client",
        height=270 * n_rows,
    )
    fig.show()
Show code
# Summary statistics by client
if not agg_total_df.empty:
    rows = []
    for client in all_clients:
        cdf = agg_total_df[agg_total_df["client"] == client].sort_values("timestamp")
        if cdf.empty or cdf["value"].max() == 0:
            continue
        dt = cdf["timestamp"].diff().dt.total_seconds()
        dv = cdf["value"].diff()
        rate = (dv / dt * SLOT_TIME).iloc[1:]
        rate = rate[rate >= 0]
        if not rate.empty:
            rows.append({"client": client, "Mean (/slot)": rate.mean(), "Min (/slot)": rate.min(), "Max (/slot)": rate.max()})
    if rows:
        display(pd.DataFrame(rows).set_index("client").round(3))
Mean (/slot) Min (/slot) Max (/slot)
client
ethlambda_0 0.107 0.000 1.067
ethlambda_10 0.000 0.000 0.000
ethlambda_11 0.220 0.000 1.200
ethlambda_12 0.170 0.000 1.267
ethlambda_13 0.063 0.000 1.067
ethlambda_14 0.119 0.000 1.067
ethlambda_15 0.016 0.000 0.063
ethlambda_2 0.165 0.000 1.067
ethlambda_3 0.172 0.000 1.133
ethlambda_4 0.516 0.000 3.133
ethlambda_5 0.168 0.000 1.067
ethlambda_6 0.112 0.000 1.067
ethlambda_7 0.056 0.000 1.067
ethlambda_8 1.067 0.067 2.333
ethlambda_9 0.053 0.000 1.067
gean_0 1.781 1.133 2.600
grandine_0 1.520 0.000 5.867
grandine_2 0.000 0.000 0.000
grandine_3 0.241 0.000 1.067
nlean_0 1.854 1.333 2.467
zeam_1 0.051 0.000 0.178
zeam_2 0.030 0.000 0.533

Signatures Verified per SlotΒΆ

Rate of valid/invalid aggregated signature verifications per slot (4-second slot time). Useful for researchers and implementers with a focus on signature aggregation in conjunction with consensus.

Show code
SLOT_TIME = 4  # seconds

# Calculate valid/invalid signature counts
valid_df = counts_df[counts_df["metric"] == "lean_pq_sig_aggregated_signatures_valid_total"]
invalid_df = counts_df[counts_df["metric"] == "lean_pq_sig_aggregated_signatures_invalid_total"]

if valid_df.empty and invalid_df.empty:
    print("No signature count data available")
else:
    fig = make_subplots(
        rows=n_rows, cols=n_cols,
        subplot_titles=all_clients,
        vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    colors = {"valid": "#2ecc71", "invalid": "#e74c3c"}
    legend_added = set()

    for i, client in enumerate(all_clients):
        row = i // n_cols + 1
        col = i % n_cols + 1
        has_data = False

        for status, sdf in [("valid", valid_df), ("invalid", invalid_df)]:
            cdf = sdf[sdf["client"] == client].sort_values("timestamp")
            if cdf.empty or cdf["value"].max() == 0:
                continue
            # Compute rate per slot: diff(value) / diff(seconds) * slot_time
            dt = cdf["timestamp"].diff().dt.total_seconds()
            dv = cdf["value"].diff()
            rate = (dv / dt * SLOT_TIME).iloc[1:]
            ts = cdf["timestamp"].iloc[1:]
            # Drop negative rates (counter resets)
            mask = rate >= 0
            rate = rate[mask]
            ts = ts[mask]
            if rate.empty:
                continue
            has_data = True
            show_legend = status not in legend_added
            legend_added.add(status)
            fig.add_trace(
                go.Scatter(
                    x=ts, y=rate,
                    name=status, legendgroup=status,
                    showlegend=show_legend,
                    line=dict(color=colors[status]),
                ),
                row=row, col=col,
            )

        if has_data:
            fig.update_yaxes(title_text="/slot", row=row, col=col)

        else:
            fig.add_trace(
                go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
                row=row, col=col,
            )
            _n = (row - 1) * n_cols + col
            _s = "" if _n == 1 else str(_n)
            fig.add_annotation(
                text="No data available",
                xref=f"x{_s} domain", yref=f"y{_s} domain",
                x=0.5, y=0.5,
                showarrow=False,
                font=dict(size=12, color="#999"),
            )
    fig.update_layout(
        title="Signatures Verified per Slot by Client",
        height=270 * n_rows,
    )
    fig.show()
Show code
# Summary statistics by client
if not valid_df.empty or not invalid_df.empty:
    rows = []
    for client in all_clients:
        for status, sdf in [("valid", valid_df), ("invalid", invalid_df)]:
            cdf = sdf[sdf["client"] == client].sort_values("timestamp")
            if cdf.empty or cdf["value"].max() == 0:
                continue
            dt = cdf["timestamp"].diff().dt.total_seconds()
            dv = cdf["value"].diff()
            rate = (dv / dt * SLOT_TIME).iloc[1:]
            rate = rate[rate >= 0]
            if not rate.empty:
                rows.append({"client": client, "status": status, "Mean (/slot)": rate.mean(), "Min (/slot)": rate.min(), "Max (/slot)": rate.max()})
    if rows:
        display(pd.DataFrame(rows).set_index(["client", "status"]).round(3))
Mean (/slot) Min (/slot) Max (/slot)
client status
ethlambda_0 valid 2.940 0.000 7.867
ethlambda_1 valid 1.702 0.000 8.933
ethlambda_10 valid 2.968 0.000 11.267
ethlambda_11 valid 2.940 0.000 7.067
ethlambda_12 valid 2.927 0.000 8.933
ethlambda_13 valid 3.344 0.000 14.467
ethlambda_14 valid 1.543 0.000 7.867
ethlambda_15 valid 9.724 1.227 24.067
ethlambda_2 valid 2.856 0.000 8.933
ethlambda_3 valid 2.968 0.000 8.933
ethlambda_4 valid 2.239 0.000 10.200
ethlambda_5 valid 2.856 0.000 8.933
ethlambda_6 valid 2.800 0.000 7.867
ethlambda_7 valid 2.744 0.000 15.533
ethlambda_8 valid 2.800 0.000 7.867
ethlambda_9 valid 2.940 0.000 14.467
grandine_0 valid 46.513 12.200 60.600
grandine_1 valid 44.337 13.733 60.600
grandine_2 valid 0.000 0.000 0.000
grandine_3 valid 0.241 0.000 1.067
grandine_4 valid 49.850 14.933 66.467
grandine_5 valid 46.787 13.533 64.067
grandine_6 valid 43.307 15.533 55.000
grandine_7 valid 50.561 18.267 76.200
ream_0 valid 5.656 0.000 13.733
ream_1 valid 2.027 0.000 10.667
ream_2 valid 2.595 0.000 7.800
ream_3 valid 1.450 0.000 6.933
ream_4 valid 4.244 1.067 7.467
ream_5 valid 3.027 0.000 7.267
ream_6 valid 0.948 0.000 7.467
ream_7 valid 2.291 0.000 5.333
zeam_0 valid 1.646 0.000 6.800
zeam_1 valid 3.386 0.278 7.867
zeam_10 valid 1.646 0.000 8.933
zeam_11 valid 1.660 0.000 8.933
zeam_12 valid 1.587 0.000 7.867
zeam_13 valid 1.646 0.000 7.867
zeam_14 valid 1.646 0.000 8.933
zeam_15 valid 1.867 0.000 8.933
zeam_2 valid 1.678 0.000 9.133
zeam_3 valid 1.740 0.000 8.933
zeam_4 valid 1.905 0.000 10.667
zeam_5 valid 2.014 0.000 13.267
zeam_6 valid 1.660 0.000 8.933
zeam_7 valid 1.702 0.000 8.933
zeam_8 valid 1.702 0.000 8.933
zeam_9 valid 1.702 0.000 7.867

Signatures Aggregated per SecondΒΆ

Rate of signature aggregations per second. Useful for researchers and implementers with a focus on signature aggregation performance.

Show code
if agg_total_df.empty:
    print("No signature aggregation count data available")
else:
    fig = make_subplots(
        rows=n_rows, cols=n_cols,
        subplot_titles=all_clients,
        vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    for i, client in enumerate(all_clients):
        row = i // n_cols + 1
        col = i % n_cols + 1
        cdf = agg_total_df[agg_total_df["client"] == client].sort_values("timestamp")
        if not cdf.empty and cdf["value"].max() > 0:
            dt = cdf["timestamp"].diff().dt.total_seconds()
            dv = cdf["value"].diff()
            rate = (dv / dt).iloc[1:]
            ts = cdf["timestamp"].iloc[1:]
            mask = rate >= 0
            rate = rate[mask]
            ts = ts[mask]
            if not rate.empty:
                fig.add_trace(
                    go.Scatter(
                        x=ts, y=rate,
                        showlegend=False,
                        line=dict(color="#636EFA"),
                    ),
                    row=row, col=col,
                )
                fig.update_yaxes(title_text="/s", row=row, col=col)
                continue
        fig.add_trace(
            go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
            row=row, col=col,
        )
        _n = (row - 1) * n_cols + col
        _s = "" if _n == 1 else str(_n)
        fig.add_annotation(
            text="No data available",
            xref=f"x{_s} domain", yref=f"y{_s} domain",
            x=0.5, y=0.5,
            showarrow=False,
            font=dict(size=12, color="#999"),
        )

    fig.update_layout(
        title="Signatures Aggregated per Second by Client",
        height=270 * n_rows,
    )
    fig.show()
Show code
# Summary statistics by client
if not agg_total_df.empty:
    rows = []
    for client in all_clients:
        cdf = agg_total_df[agg_total_df["client"] == client].sort_values("timestamp")
        if cdf.empty or cdf["value"].max() == 0:
            continue
        dt = cdf["timestamp"].diff().dt.total_seconds()
        dv = cdf["value"].diff()
        rate = (dv / dt).iloc[1:]
        rate = rate[rate >= 0]
        if not rate.empty:
            rows.append({"client": client, "Mean (/s)": rate.mean(), "Min (/s)": rate.min(), "Max (/s)": rate.max()})
    if rows:
        display(pd.DataFrame(rows).set_index("client").round(3))
Mean (/s) Min (/s) Max (/s)
client
ethlambda_0 0.027 0.000 0.267
ethlambda_10 0.000 0.000 0.000
ethlambda_11 0.055 0.000 0.300
ethlambda_12 0.042 0.000 0.317
ethlambda_13 0.016 0.000 0.267
ethlambda_14 0.030 0.000 0.267
ethlambda_15 0.004 0.000 0.016
ethlambda_2 0.041 0.000 0.267
ethlambda_3 0.043 0.000 0.283
ethlambda_4 0.129 0.000 0.783
ethlambda_5 0.042 0.000 0.267
ethlambda_6 0.028 0.000 0.267
ethlambda_7 0.014 0.000 0.267
ethlambda_8 0.267 0.017 0.583
ethlambda_9 0.013 0.000 0.267
gean_0 0.445 0.283 0.650
grandine_0 0.380 0.000 1.467
grandine_2 0.000 0.000 0.000
grandine_3 0.060 0.000 0.267
nlean_0 0.464 0.333 0.617
zeam_1 0.013 0.000 0.044
zeam_2 0.007 0.000 0.133

Signatures Verified per SecondΒΆ

Rate of valid/invalid aggregated signature verifications per second. Useful for researchers and implementers with a focus on signature aggregation performance.

Show code
if valid_df.empty and invalid_df.empty:
    print("No signature count data available")
else:
    fig = make_subplots(
        rows=n_rows, cols=n_cols,
        subplot_titles=all_clients,
        vertical_spacing=0.12 / max(n_rows - 1, 1) * 2,
        horizontal_spacing=0.08,
    )

    colors = {"valid": "#2ecc71", "invalid": "#e74c3c"}
    legend_added = set()

    for i, client in enumerate(all_clients):
        row = i // n_cols + 1
        col = i % n_cols + 1
        has_data = False

        for status, sdf in [("valid", valid_df), ("invalid", invalid_df)]:
            cdf = sdf[sdf["client"] == client].sort_values("timestamp")
            if cdf.empty or cdf["value"].max() == 0:
                continue
            # Compute rate: diff(value) / diff(timestamp) in per-second
            dt = cdf["timestamp"].diff().dt.total_seconds()
            dv = cdf["value"].diff()
            rate = (dv / dt).iloc[1:]  # per second, skip first NaN
            ts = cdf["timestamp"].iloc[1:]
            # Drop negative rates (counter resets)
            mask = rate >= 0
            rate = rate[mask]
            ts = ts[mask]
            if rate.empty:
                continue
            has_data = True
            show_legend = status not in legend_added
            legend_added.add(status)
            fig.add_trace(
                go.Scatter(
                    x=ts, y=rate,
                    name=status, legendgroup=status,
                    showlegend=show_legend,
                    line=dict(color=colors[status]),
                ),
                row=row, col=col,
            )

        if has_data:
            fig.update_yaxes(title_text="/s", row=row, col=col)

        else:
            fig.add_trace(
                go.Scatter(x=[None], y=[None], showlegend=False, hoverinfo='skip'),
                row=row, col=col,
            )
            _n = (row - 1) * n_cols + col
            _s = "" if _n == 1 else str(_n)
            fig.add_annotation(
                text="No data available",
                xref=f"x{_s} domain", yref=f"y{_s} domain",
                x=0.5, y=0.5,
                showarrow=False,
                font=dict(size=12, color="#999"),
            )
    fig.update_layout(
        title="Signatures Verified per Second by Client",
        height=270 * n_rows,
    )
    fig.show()
Show code
# Summary statistics by client
if not valid_df.empty or not invalid_df.empty:
    rows = []
    for client in all_clients:
        for status, sdf in [("valid", valid_df), ("invalid", invalid_df)]:
            cdf = sdf[sdf["client"] == client].sort_values("timestamp")
            if cdf.empty or cdf["value"].max() == 0:
                continue
            dt = cdf["timestamp"].diff().dt.total_seconds()
            dv = cdf["value"].diff()
            rate = (dv / dt).iloc[1:]
            rate = rate[rate >= 0]
            if not rate.empty:
                rows.append({"client": client, "status": status, "Mean (/s)": rate.mean(), "Min (/s)": rate.min(), "Max (/s)": rate.max()})
    if rows:
        display(pd.DataFrame(rows).set_index(["client", "status"]).round(3))
Mean (/s) Min (/s) Max (/s)
client status
ethlambda_0 valid 0.735 0.000 1.967
ethlambda_1 valid 0.425 0.000 2.233
ethlambda_10 valid 0.742 0.000 2.817
ethlambda_11 valid 0.735 0.000 1.767
ethlambda_12 valid 0.732 0.000 2.233
ethlambda_13 valid 0.836 0.000 3.617
ethlambda_14 valid 0.386 0.000 1.967
ethlambda_15 valid 2.431 0.307 6.017
ethlambda_2 valid 0.714 0.000 2.233
ethlambda_3 valid 0.742 0.000 2.233
ethlambda_4 valid 0.560 0.000 2.550
ethlambda_5 valid 0.714 0.000 2.233
ethlambda_6 valid 0.700 0.000 1.967
ethlambda_7 valid 0.686 0.000 3.883
ethlambda_8 valid 0.700 0.000 1.967
ethlambda_9 valid 0.735 0.000 3.617
grandine_0 valid 11.628 3.050 15.150
grandine_1 valid 11.084 3.433 15.150
grandine_2 valid 0.000 0.000 0.000
grandine_3 valid 0.060 0.000 0.267
grandine_4 valid 12.462 3.733 16.617
grandine_5 valid 11.697 3.383 16.017
grandine_6 valid 10.827 3.883 13.750
grandine_7 valid 12.640 4.567 19.050
ream_0 valid 1.414 0.000 3.433
ream_1 valid 0.507 0.000 2.667
ream_2 valid 0.649 0.000 1.950
ream_3 valid 0.362 0.000 1.733
ream_4 valid 1.061 0.267 1.867
ream_5 valid 0.757 0.000 1.817
ream_6 valid 0.237 0.000 1.867
ream_7 valid 0.573 0.000 1.333
zeam_0 valid 0.411 0.000 1.700
zeam_1 valid 0.846 0.069 1.967
zeam_10 valid 0.411 0.000 2.233
zeam_11 valid 0.415 0.000 2.233
zeam_12 valid 0.397 0.000 1.967
zeam_13 valid 0.411 0.000 1.967
zeam_14 valid 0.411 0.000 2.233
zeam_15 valid 0.467 0.000 2.233
zeam_2 valid 0.419 0.000 2.283
zeam_3 valid 0.435 0.000 2.233
zeam_4 valid 0.476 0.000 2.667
zeam_5 valid 0.504 0.000 3.317
zeam_6 valid 0.415 0.000 2.233
zeam_7 valid 0.425 0.000 2.233
zeam_8 valid 0.425 0.000 2.233
zeam_9 valid 0.425 0.000 1.967

SummaryΒΆ

Key findings from this devnet iteration:

Show code
# Generate summary statistics
print(f"Devnet: {devnet_id}")
print(f"Duration: {devnet_info['duration_hours']:.1f} hours")
print(f"Clients analyzed: {len(timing_df['client'].unique())}")
print()

if not agg_build_df.empty:
    p95_agg = agg_build_df[agg_build_df["quantile"] == 0.95]["value_ms"].mean()
    print(f"Average P95 aggregation time: {p95_agg:.2f} ms")

if not agg_ver_df.empty:
    p95_ver = agg_ver_df[agg_ver_df["quantile"] == 0.95]["value_ms"].mean()
    print(f"Average P95 proof verification time: {p95_ver:.2f} ms")
Devnet: pqdevnet-20260517T0939Z
Duration: 1.1 hours
Clients analyzed: 64

Average P95 proof verification time: 262.63 ms