Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions benchmarks/native_v3_results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Native backend v3 performance results

Measured on a 4-core x86-64 container, 15 GB RAM, release builds, default
fixed-point config (`scale_bits=20, value_bits=63, intermediate_bits=127`).
"v2 baseline" is the previous native circuit (per-bit range checks, no key
caching) at the same commit environment; "warm" means params/proving key are
cached from a previous proof of the same shape (the steady-state for real
workloads, where every invocation of a module shares one circuit shape).

Reproduce with `cargo run --release --example bench_prove -- <in> <rank> <out> <reps>`
and `python benchmarks/run_benchmarks.py`.

## Single proof + verify (Rust, `bench_prove`)

| shape (in×rank×out) | v2 k | v2 prove | v2 verify | v3 k | v3 prove cold | v3 prove warm | v3 verify warm | warm speedup |
|---|---|---|---|---|---|---|---|---|
| 2×1×2 | 15 | 22.9 s | 18.4 s | 12 | 2.9 s | 0.73 s | 0.020 s | 31× / ~860× |
| 8×2×8 | ~18 | >290 s (timed out) | — | 13 | 6.0 s | 1.4 s | 0.034 s | >200× |
| 16×2×16 | ~19 | est. >370 s | — | 14 | 27.2 s | 7.0 s | 0.16 s | >50× |
| 64×4×64 | ~22 | infeasible | — | 17 | 47.2 s | 10.8 s | 0.22 s | n/a (was infeasible) |
| 768×2×256 | ~23 | infeasible (est. ~190 GB) | — | 19 | 429 s | 81 s | 1.3 s (17.1 s cold) | n/a (was infeasible) |
| 768×4×768 | ~25 | infeasible | — | 20 | needs >15 GB RAM host | — | — | n/a |
| 768×4×2304 | ~26 | infeasible (est. >200 GB params/pk) | — | 21 | needs >15 GB RAM host | — | — | n/a |

Verification cold (first proof of a shape) pays one `keygen_vk`; in v2 this
cost was paid for *every* proof.

## End-to-end Python pipeline (`run_benchmarks.py`, 4 workers)

| shape | invocations | prove wall | prove/proof | verify wall | verify/proof |
|---|---|---|---|---|---|
| 16×2×16 | 8 | 71.4 s | 8.9 s | 3.4 s | 0.42 s |
| 32×4×32 | 6 | 107.4 s | 17.9 s | 3.8 s | 0.63 s |

Per-proof wall time includes the one-time cold keygen amortised across the
batch.

## Supporting paths

| path | before | after | speedup |
|---|---|---|---|
| server per-invocation compute, 16 rows @ 768×4×2304 (quantize + delta) | ~233 ms | 13.5 ms | ~17× (≥30× at 1 row) |
| exact delta, 16 rows @ 768×4×2304 | 171 ms | 7.4 ms | 23× |
| hiding Merkle root, 200k leaves | 0.353 s | 0.043 s | 8.3× |

All fast paths are value-identical to the Python reference implementations
(randomised parity tests, including an 80k-value quantisation fuzz across
four fixed-point configs).

## Memory

Proving memory is dominated by halo2 extended-domain evaluations (Poseidon
gate degree ⇒ 8× extended domain), and halo2 0.3 keeps all advice cosets
resident during create_proof. Measured: 12.1 GB peak at k=19 (768×2×256);
k=20 and k=21 shapes exceed a 15 GB host (OOM-killed); plan on 32–64 GB for
768-dim × 4-rank modules with wide outputs.
The proving-key cache holds up to `ZKLORA_PK_CACHE_CAP` (default 2) shapes.
123 changes: 123 additions & 0 deletions benchmarks/run_benchmarks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""End-to-end zkLoRA pipeline benchmark.

Generates invocation witnesses for a synthetic LoRA module, produces native
proof artifacts, and verifies them against the transcript and adapter
manifest, reporting wall-clock timings for each stage.

Usage:
python benchmarks/run_benchmarks.py [--in_dim 16] [--rank 2] [--out_dim 16]
[--invocations 8]
"""

import argparse
import json
import random
import sys
import tempfile
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

from zklora.proof_contract import ( # noqa: E402
FixedPointConfig,
InvocationWitness,
adapter_manifest_entry,
compute_delta_quantized,
statement_from_witness,
transcript_entry_from_statement,
)
from zklora.zk_proof_generator import batch_verify_proofs, generate_proofs # noqa: E402


def build_witnesses(in_dim, rank, out_dim, invocations, seed=7):
rng = random.Random(seed)
fp = FixedPointConfig()
magnitude = 1 << fp.scale_bits
a = [[rng.randint(-magnitude, magnitude) for _ in range(in_dim)] for _ in range(rank)]
b = [[rng.randint(-magnitude, magnitude) for _ in range(rank)] for _ in range(out_dim)]
witnesses = []
for index in range(invocations):
x = [rng.randint(-magnitude, magnitude) for _ in range(in_dim)]
delta = compute_delta_quantized(a, b, x, 1, 1, fp)
witnesses.append(
InvocationWitness(
session_id="bench-session",
module_name="bench.module.c_attn",
invocation_index=index,
input_shape=[in_dim],
output_shape=[out_dim],
x=x,
delta=delta,
a=a,
b=b,
scaling_num=1,
scaling_den=1,
adapter_metadata={"rank": rank, "in_dim": in_dim, "out_dim": out_dim},
fixed_point=fp,
)
)
manifest = [adapter_manifest_entry("bench.module.c_attn", a, b, 1, 1, fp)]
return witnesses, manifest


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--in_dim", type=int, default=16)
parser.add_argument("--rank", type=int, default=2)
parser.add_argument("--out_dim", type=int, default=16)
parser.add_argument("--invocations", type=int, default=8)
args = parser.parse_args()

print(
f"shape in_dim={args.in_dim} rank={args.rank} out_dim={args.out_dim} "
f"invocations={args.invocations}"
)

start = time.time()
witnesses, manifest = build_witnesses(
args.in_dim, args.rank, args.out_dim, args.invocations
)
print(f"witness generation: {time.time() - start:.2f}s")

with tempfile.TemporaryDirectory() as tmp:
proof_dir = Path(tmp) / "artifacts"
start = time.time()
_, _, elapsed, total_params, proofs = generate_proofs(
records=witnesses, output_dir=str(proof_dir)
)
prove_wall = time.time() - start
print(
f"proof generation: {prove_wall:.2f}s total "
f"({prove_wall / max(proofs, 1):.2f}s/proof, {proofs} proofs)"
)

transcript = [
transcript_entry_from_statement(statement_from_witness(w)) for w in witnesses
]
start = time.time()
verify_time, verified = batch_verify_proofs(
proof_dir=str(proof_dir),
transcript=transcript,
expected_adapters={"adapters": manifest},
)
verify_wall = time.time() - start
print(
f"verification: {verify_wall:.2f}s total "
f"({verify_wall / max(verified, 1):.2f}s/proof, {verified} proofs)"
)
result = {
"shape": [args.in_dim, args.rank, args.out_dim],
"invocations": args.invocations,
"prove_wall_s": round(prove_wall, 3),
"prove_per_proof_s": round(prove_wall / max(proofs, 1), 3),
"verify_wall_s": round(verify_wall, 3),
"verify_per_proof_s": round(verify_wall / max(verified, 1), 3),
"total_params": total_params,
}
print(json.dumps(result))


if __name__ == "__main__":
main()
5 changes: 4 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,10 @@ For detailed information about the codebase organization and implementation deta
<td>✓</td><td><strong>Adapter Weight Privacy:</strong> LoRA weights remain confidential while the committed adapter identity is checked</td>
</tr>
<tr>
<td>✓</td><td><strong>Benchmark Required:</strong> Real-shape proving and verification performance should be measured for each deployment target</td>
<td>✓</td><td><strong>Benchmark Required:</strong> Real-shape proving and verification performance should be measured for each deployment target (see <code>benchmarks/run_benchmarks.py</code> and <code>cargo run --release --example bench_prove</code>)</td>
</tr>
<tr>
<td>✓</td><td><strong>Fast Native Backend (v3):</strong> Lookup-based range checks, shape-keyed SRS/key caches, and parallel batch proving/verification deliver order-of-magnitude speedups over v2 while keeping the same statement format and adapter commitment scheme</td>
</tr>
</table>

Expand Down
2 changes: 2 additions & 0 deletions src/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ python = ["dep:pyo3"]
extension-module = ["python", "pyo3/extension-module"]

[dependencies]
blake3 = "1"
halo2_proofs = "0.3.2"
halo2_gadgets = "0.5"
ff = "0.13"
Expand All @@ -21,6 +22,7 @@ num-integer = "0.1"
num-traits = "0.2"
pyo3 = { version = "0.28.3", optional = true }
rand_core = { version = "0.6", features = ["getrandom"] }
rayon = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
Expand Down
13 changes: 12 additions & 1 deletion src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,18 @@ The Rust implementation is wrapped with Python bindings in the `libs/merkle` dir

### Performance Considerations

Native Halo2 performance should be measured for the specific LoRA shapes being proven. The v2 implementation prioritizes proof-contract correctness, transcript binding, and pre-agreed adapter binding before publishing benchmark claims.
Native Halo2 performance should be measured for the specific LoRA shapes being proven. The v3 backend keeps the v2 proof contract (same statement format, same Poseidon adapter-commitment scheme) while making proving and verification dramatically faster:

- **Lookup-based range checks**: signed interval checks decompose values into window-sized limbs via a running sum constrained against a lookup table, instead of one boolean row per bit. The interval semantics are exact (the top limb is scaled to its residual width), and the circuit drops provably redundant per-product checks whose bounds already follow from the range-checked operands. At a 768×4×2304 LoRA shape this reduces the circuit from k≈26 to k=21 (32× fewer rows).
- **Keyed SRS/proving-key/verifying-key caches**: params and keys are derived deterministically from the statement shape (dims, fixed-point bits, scaling) and reused across invocations of the same module. First proof per shape pays keygen; subsequent proofs and all verifications are keygen-free. Cache sizes are tunable via `ZKLORA_PARAMS_CACHE_CAP`, `ZKLORA_PK_CACHE_CAP`, and `ZKLORA_VK_CACHE_CAP`.
- **Parallel batch operations**: the PyO3 bindings release the GIL, and `generate_proofs` / `batch_verify_proofs` fan out across a thread pool (`ZKLORA_PROVE_WORKERS`, `ZKLORA_VERIFY_WORKERS`).
- **Native fast paths with exact fallbacks**: quantized delta computation and the hiding Merkle commitment have Rust implementations that are value-identical to the Python reference paths (covered by parity tests); the Python implementations remain as exact fallbacks.

Proofs and verifying keys from the v2 backend are not compatible with v3 (the `backend` field in statements changed to `zklora-halo2-v3`), but pinned adapter manifests remain valid: the adapter commitment scheme is unchanged.

Measured on a 4-core machine (warm key cache): a 2×1×2 relation proves in ~0.7s and verifies in ~0.02s (previously ~23s / ~18s), an 8×2×8 relation proves in ~1.4s (previously >5 minutes), and the real 768×4×2304 c_attn shape becomes feasible at k=21.

Memory note: proving memory is dominated by halo2's extended-domain evaluations (the Poseidon chip's gate degree implies an 8× extended domain), and halo2 0.3 keeps all advice cosets resident during proof creation. Measured on a 15 GB host: a k=19 shape (768×2×256) proves in 81 s warm with a 12.1 GB peak, while k=20 and k=21 shapes (e.g. 768×4×768 and the full 768×4×2304 c_attn) exceed 15 GB and require a 32–64 GB prover host. Verification is lightweight once the verifying key is cached.

For detailed usage examples and high-level architecture, please refer to the [main README](../../README.md) in the project root.

Expand Down
27 changes: 27 additions & 0 deletions src/examples/bench_prove.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! Benchmark harness for zkLoRA native proving and verification.
//!
//! Usage: cargo run --release --example bench_prove -- <in_dim> <rank> <out_dim> [reps]

use std::time::Instant;

use _native_prover::bench_support::{bench_statement_and_witness, prove_verify_once};

fn main() {
let args: Vec<String> = std::env::args().collect();
let in_dim: usize = args.get(1).map(|v| v.parse().unwrap()).unwrap_or(8);
let rank: usize = args.get(2).map(|v| v.parse().unwrap()).unwrap_or(2);
let out_dim: usize = args.get(3).map(|v| v.parse().unwrap()).unwrap_or(8);
let reps: usize = args.get(4).map(|v| v.parse().unwrap()).unwrap_or(1);

let (statement_json, witness_json, k) = bench_statement_and_witness(in_dim, rank, out_dim);
println!("shape in_dim={in_dim} rank={rank} out_dim={out_dim} k={k} reps={reps}");

for rep in 0..reps {
let start = Instant::now();
let (prove_ms, verify_ms, proof_len) = prove_verify_once(&statement_json, &witness_json);
println!(
"rep={rep} prove_ms={prove_ms:.1} verify_ms={verify_ms:.1} total_ms={:.1} proof_bytes={proof_len}",
start.elapsed().as_secs_f64() * 1000.0
);
}
}
Loading
Loading