PQ Signature Performance

Analysis of post-quantum cryptographic signature performance in Lean Consensus clients.

This notebook examines:

  • Attestation signing time (p50, p95, p99)
  • Attestation verification time
  • Signature counts (total, valid, invalid)
  • 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-20260512T1732Z
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-20260512T1732Z
Duration: 0.4 hours
Time: 2026-05-12T17:32:57+00:00 to 2026-05-12T17:59:09+00:00
Slots: 1775 → 3697
Clients: ethlambda_0, ethlambda_1, gean_0, gean_1, grandine_0, grandine_1, lantern_0, lantern_1, nlean_0, nlean_1, qlean_0, qlean_1, ream_0, ream_1, zeam_0, zeam_1

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 744 timing records
Metrics: ['signing', 'verification', 'agg_verification']
Clients: ['ethlambda_1', 'ethlambda_0', 'gean_1', 'gean_0', 'grandine_1', 'grandine_0', 'lantern_1', 'lantern_0', 'nlean_1', 'nlean_0', 'qlean_1', 'qlean_0', 'zeam_0', 'zeam_1', 'ream_0', 'ream_1']
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 1450 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 (16): ['ethlambda_0', 'ethlambda_1', 'gean_0', 'gean_1', 'grandine_0', 'grandine_1', 'lantern_0', 'lantern_1', 'nlean_0', 'nlean_1', 'qlean_0', 'qlean_1', 'ream_0', 'ream_1', 'zeam_0', 'zeam_1']

Attestation Signature Counts

Total aggregated signatures produced by each client, broken down by validation result.

Show code
# Signature counts over time per client
# These are cumulative Prometheus counters — plot as-is to show growth over time.
from IPython.display import HTML, display

count_metrics = {
    "lean_pq_sig_aggregated_signatures_total": ("Total", "#636EFA"),
    "lean_pq_sig_aggregated_signatures_valid_total": ("Valid", "#00CC96"),
    "lean_pq_sig_aggregated_signatures_invalid_total": ("Invalid", "#EF553B"),
}

if counts_df.empty:
    print("No signature count data available")
else:
    sig_df = counts_df[counts_df["metric"].isin(count_metrics)].copy()

    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,
    )

    legend_added = set()

    for i, client in enumerate(all_clients):
        row = i // n_cols + 1
        col = i % n_cols + 1
        cdf = sig_df[sig_df["client"] == client]
        if not cdf.empty:
            for metric_name, (label, color) in count_metrics.items():
                mdf = cdf[cdf["metric"] == metric_name].sort_values("timestamp")
                if mdf.empty:
                    continue
                show_legend = label not in legend_added
                legend_added.add(label)
                fig.add_trace(
                    go.Scatter(
                        x=mdf["timestamp"], y=mdf["value"],
                        name=label, legendgroup=label,
                        showlegend=show_legend,
                        line=dict(color=color),
                    ),
                    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="Cumulative Attestation Signature Counts by Client",
        height=270 * n_rows,
    )
    fig.show()

Attestation Signing Time

How long does it take to sign an attestation using post-quantum cryptography?

Show code
# Filter to signing time metric
signing_df = timing_df[timing_df["metric"] == "signing"].copy()

if signing_df.empty:
    print("No signing time data available")
else:
    # Convert to milliseconds for readability
    signing_df["value_ms"] = signing_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 = signing_df[signing_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,
                )
            fig.update_yaxes(title_text="ms", 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="Attestation Signing Time by Client",
        height=270 * n_rows,
    )
    fig.show()
Show code
# Summary statistics by client
if not signing_df.empty:
    summary = signing_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 9.630 6.944 12.500
0.95 47.767 43.472 58.750
0.99 79.125 66.500 91.750
ethlambda_1 0.50 13.644 10.000 16.938
0.95 52.150 40.000 87.500
0.99 299.222 66.000 790.000
gean_0 0.50 8.625 8.036 9.583
0.95 39.013 34.000 42.375
0.99 47.803 46.800 48.475
gean_1 0.50 9.215 7.500 12.400
0.95 41.929 32.500 47.500
0.99 65.791 48.031 88.500
grandine_0 0.50 20.514 19.293 21.686
0.95 48.795 46.250 53.125
0.99 79.493 49.250 90.625
grandine_1 0.50 18.881 17.500 20.435
0.95 48.783 47.000 53.333
0.99 122.417 81.000 316.000
lantern_0 0.50 11.253 8.382 14.167
0.95 44.496 37.292 49.615
0.99 67.807 47.458 89.333
lantern_1 0.50 15.604 10.000 18.571
0.95 59.583 47.500 76.667
0.99 81.917 49.500 95.333
nlean_0 0.50 22.106 20.610 23.790
0.95 53.099 46.136 64.000
0.99 82.859 49.227 92.800
nlean_1 0.50 18.928 18.112 19.734
0.95 46.333 44.375 47.721
0.99 72.750 63.000 82.250
qlean_0 0.50 6.466 6.000 7.065
0.95 28.787 23.807 43.864
0.99 48.875 40.625 63.000
qlean_1 0.50 6.296 4.390 7.262
0.95 27.558 24.738 32.000
0.99 52.696 45.187 66.500
zeam_0 0.50 10.463 8.750 13.250
0.95 46.347 42.604 49.018
0.99 73.642 48.521 88.167
zeam_1 0.50 10.058 7.935 11.389
0.95 42.442 40.139 45.446
0.99 53.739 48.028 64.500

Attestation Verification Time

How long does it take to verify an attestation signature?

Show code
# Filter to verification time metric
verification_df = timing_df[timing_df["metric"] == "verification"].copy()

if verification_df.empty:
    print("No verification time data available")
else:
    verification_df["value_ms"] = verification_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 = verification_df[verification_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,
                )
            fig.update_yaxes(title_text="ms", 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="Attestation Verification Time by Client",
        height=270 * n_rows,
    )
    fig.show()
Show code
# Summary statistics by client
if not verification_df.empty:
    summary = verification_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 2.503 2.500 2.509
0.95 4.755 4.750 4.767
0.99 4.955 4.950 4.967
ethlambda_1 0.50 2.542 2.532 2.557
0.95 4.830 4.811 4.858
0.99 6.863 6.069 7.758
gean_0 0.50 NaN NaN NaN
0.95 NaN NaN NaN
0.99 NaN NaN NaN
gean_1 0.50 NaN NaN NaN
0.95 NaN NaN NaN
0.99 NaN NaN NaN
grandine_0 0.50 2.500 2.500 2.500
0.95 4.750 4.750 4.750
0.99 4.950 4.950 4.950
grandine_1 0.50 2.500 2.500 2.500
0.95 4.750 4.750 4.750
0.99 4.950 4.950 4.950
lantern_0 0.50 2.500 2.500 2.500
0.95 4.750 4.750 4.750
0.99 4.950 4.950 4.950
lantern_1 0.50 2.517 2.500 2.541
0.95 4.782 4.750 4.828
0.99 5.553 4.950 6.900
nlean_0 0.50 NaN NaN NaN
0.95 NaN NaN NaN
0.99 NaN NaN NaN
nlean_1 0.50 NaN NaN NaN
0.95 NaN NaN NaN
0.99 NaN NaN NaN
qlean_0 0.50 2.500 2.500 2.500
0.95 4.750 4.750 4.750
0.99 4.950 4.950 4.950
qlean_1 0.50 2.500 2.500 2.500
0.95 4.750 4.750 4.750
0.99 4.950 4.950 4.950
zeam_0 0.50 2.500 2.500 2.500
0.95 4.750 4.750 4.750
0.99 4.950 4.950 4.950
zeam_1 0.50 2.500 2.500 2.500
0.95 4.750 4.750 4.750
0.99 4.950 4.950 4.950

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 signing_df.empty:
    p95_mean = signing_df[signing_df["quantile"] == 0.95]["value_ms"].mean()
    print(f"Average P95 signing time: {p95_mean:.2f} ms")

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

if not counts_df.empty:
    sig_totals = counts_df[counts_df["metric"] == "lean_pq_sig_aggregated_signatures_total"].groupby("client")["value"].max()
    valid_totals = counts_df[counts_df["metric"] == "lean_pq_sig_aggregated_signatures_valid_total"].groupby("client")["value"].max()
    invalid_totals = counts_df[counts_df["metric"] == "lean_pq_sig_aggregated_signatures_invalid_total"].groupby("client")["value"].max()
    print(f"\nTotal signatures: {int(sig_totals.sum()):,}")
    print(f"Valid signatures: {int(valid_totals.sum()):,}")
    print(f"Invalid signatures: {int(invalid_totals.sum()):,}")
Devnet: pqdevnet-20260512T1732Z
Duration: 0.4 hours
Clients analyzed: 16

Average P95 signing time: 45.09 ms
Average P95 verification time: 4.76 ms

Total signatures: 18,046
Valid signatures: 56,298
Invalid signatures: 4