Traduit de l'article original en anglais. Lire en anglais

Les preuves à divulgation nulle de connaissance permettent un calcul vérifiable sans exposer les transactions privées, ce qui en fait une brique puissante pour les transactions respectueuses de la vie privée et la vérification onchain à grande échelle. Cependant, l'intensité de calcul de la génération de preuves a longtemps été un problème majeur. Un compromis bien connu des systèmes ZK est celui du « prover gourmand en ressources » : obtenir une preuve petite et rapide à vérifier demande beaucoup de calcul, et c'est souvent assez lent, ce qui bloque les usages en temps réel.
Les GPU modernes changent radicalement cette équation. En tirant parti de leur architecture parallèle, ils transforment ce qui était un problème fondamental en un problème d'ingénierie soluble. Les systèmes ZK d'aujourd'hui reposent principalement sur deux constructions : zk-SNARKs et zk-STARKs, qui s'appuient toutes deux sur des opérations de base qui se prêtent parfaitement à l'accélération GPU.
Presque tout le temps de génération de preuve est consacré à deux opérations : la Multi-Scalar Multiplication (MSM) et la Number Theoretic Transform (NTT).
La MSM calcule la somme de points de courbe elliptique multipliés par des scalaires : . Cette seule opération consomme à elle seule 60-80% du temps de génération de preuve dans les SNARKs. Elle nécessite en soi un grand nombre d'accès mémoire aléatoires pour récupérer les points de courbe elliptique, ce qui fait de la bande passante mémoire le principal problème de performance ici. L'algorithme de Pippenger parallélise cette tâche en répartissant les scalaires dans des buckets.
fn pippenger_msm<G: CurveGroup>(
bases: &[G::Affine],
scalars: &[G::ScalarField],
window_size: usize,
) -> G {
let num_buckets = 1 << window_size;
let mut buckets = vec![G::zero(); num_buckets];
for (base, scalar) in bases.iter().zip(scalars.iter()) {
let bucket_index = extract_window(scalar, window_size);
buckets[bucket_index] += base;
}
// bucket aggregation
buckets.into_iter()
.enumerate()
.map(|(i, bucket)| bucket * G::ScalarField::from(i))
.fold(G::zero(), |acc, term| acc + term)
}Ce qui se passe dans l'implémentation GPU, c'est qu'elle répartit ce travail sur des milliers de threads, et atteint plus de 700 million d'opérations sur des points par seconde sur une RTX 4090.
La NTT, de son côté, multiplie rapidement des polynômes, et contrairement aux sauts mémoire aléatoires de la MSM, la NTT suit un schéma prévisible, exactement ce pour quoi les GPU sont conçus.
__global__ void ntt_radix2_kernel(
uint64_t* coeffs,
uint64_t* twiddles,
uint32_t stage,
uint32_t n
) {
uint32_t tid = threadIdx.x + blockIdx.x * blockDim.x;
uint32_t stride = 1 << stage;
uint32_t m = n >> (stage + 1);
if (tid >= m) return;
uint32_t k = tid % stride;
uint32_t j = tid / stride;
uint32_t idx1 = j * (stride << 1) + k;
uint32_t idx2 = idx1 + stride;
uint64_t u = coeffs[idx1];
uint64_t v = mod_mul(coeffs[idx2], twiddles[k], MODULUS);
coeffs[idx1] = mod_add(u, v, MODULUS);
coeffs[idx2] = mod_sub(u, v, MODULUS);
}Ce schéma séquentiel permet aux implémentations NTT performantes d'atteindre 85-95% de la bande passante mémoire.
Imaginez Alice et Bob, qui exploitent tous les deux des zkEVM Sequencers pour leurs chaînes Layer-2 respectives. Ils traitent des lots de transactions identiques, mais utilisent une infra de génération de preuves différente.
Les deux séquenceurs doivent prouver un bloc contenant 500 transactions avec les opérations suivantes :
Cela génère un circuit d'environ contraintes, ce qui est typique d'un bloc zkEVM de taille moyenne.
Alice fait tourner son prover sur un CPU haut de gamme - AMD EPYC 7763, 64 cores :
impl CpuZkEvmProver {
fn prove_block(&self, block: &Block) -> ProofResult {
let start = Instant::now();
// circuit compilation
let circuit = self.compile_block_circuit(block); // 45s
// msm operations
let witness_commitment = self.cpu_msm(&circuit.witnesses); // 180s
// ntt operations
let constraint_proof = self.cpu_ntt(&circuit.constraints); // 90s
// final proof
let proof = self.assemble_proof(witness_commitment, constraint_proof);
ProofResult {
proof,
proving_time: start.elapsed(), // ~315s total
memory_usage: 45_000_000_000, // 45GB peak
}
}
fn cpu_msm(&self, witnesses: &[Fr]) -> G1Projective {
witnesses.iter()
.zip(self.setup.iter())
.map(|(scalar, base)| base.mul(scalar))
.fold(G1Projective::identity(), |acc, point| acc + point)
}
}Les résultats d'Alice :
Bob utilise une RTX 4090 avec l'accélération ICICLE :
use icicle_bls12_381::{curve::G1Projective, msm};
use icicle_core::msm::MSMConfig;
impl GpuZkEvmProver {
fn prove_block(&self, block: &Block) -> ProofResult {
let start = Instant::now();
// circuit compilation
let circuit = self.compile_block_circuit_gpu(block); // 12s
// gpu msm - 16,384 cores parallel
let witness_commitment = self.gpu_msm(&circuit.witnesses); // 8s
// gpu ntt
let constraint_proof = self.gpu_ntt(&circuit.constraints); // 3 seconds
// final proof
let proof = self.assemble_proof(witness_commitment, constraint_proof);
ProofResult {
proof,
proving_time: start.elapsed(), // ~23s total
memory_usage: 18_000_000_000, // 18GB peak
}
}
fn gpu_msm(&self, witnesses: &[Fr]) -> Result<G1Projective> {
let cfg = MSMConfig {
precompute_factor: 8,
c: 16,
..Default::default()
};
msm::msm(witnesses, &self.gpu_setup, &cfg)
}
}Les résultats de Bob :
| Opération | Alice (CPU) | Bob (GPU) | Accélération |
|---|---|---|---|
| Compilation du circuit | 45s | 12s | 3,8× |
| Opérations MSM | 180s | 8s | 22,5× |
| Opérations NTT | 90s | 3s | 30× |
| Preuve complète du bloc | 315s | 23s | 13,7× |
Finalité du réseau :
// one every 12s
const BLOCKS_PER_DAY: u32 = 7200;
impl ProvingEconomics {
fn daily_capacity(&self, proving_time_seconds: u32) -> u32 {
let daily_seconds = 24 * 60 * 60;
daily_seconds / proving_time_seconds
}
}Capacité quotidienne de génération de preuves (Alice vs Bob) :
Alice aurait besoin de 14 CPU pour égaler la configuration à un seul GPU de Bob, ce qui entraîne :
La différence de performance vient fondamentalement de la façon dont les deux serveurs accèdent à la mémoire :
// alice's cpu msm - sequential, cache-bound
for (scalar, base) in witnesses.iter().zip(setup.iter()) {
result += base * scalar; // cache miss every iteration
}
// bob's gpu msm - parallel, lightweight
__global__ void msm_kernel(scalar_t* scalars, point_t* bases, point_t* buckets) {
int tid = blockIdx.x * blockDim.x + threadIdx.x;
int bucket_idx = extract_window(scalars[tid], WINDOW_SIZE);
atomicAdd(&buckets[bucket_idx], bases[tid]); // coalesced memory access
}Cette étude de cas montre simplement pourquoi l'accélération GPU est devenue indispensable pour les déploiements zkEVM en production. L'infra de Bob peut servir 13.7× plus d'utilisateurs tout en conservant une finalité plus rapide et des coûts opérationnels plus bas.
Groth16 et PLONK sont dominés par la MSM. Groth16, par exemple, consacre environ 80% de son temps de génération de preuve à des opérations sur courbes elliptiques. PLONK, en revanche, répartit la charge de façon plus équilibrée entre la MSM ~60% et les opérations polynomiales ~30%, ce qui demande une optimisation équilibrée des deux.
impl<E: Pairing> Groth16Prover<E> {
fn create_proof(&self, circuit: &Circuit<E::ScalarField>) -> Result<Proof<E>> {
let (a, b, c) = circuit.to_matrices();
let g_a = self.params.alpha_g1.msm(&a);
let g_b = self.params.beta_g2.msm(&b);
let g_c = self.params.gamma_g1.msm(&c);
Ok(Proof { a: g_a, b: g_b, c: g_c })
}
}Les STARKs sont dominés par l'arithmétique polynomiale NTTs et le hachage, par exemple Poseidon ou Blake3. Du fait de la régularité des schémas d'accès mémoire dans les STARKs, ils exploitent souvent mieux la bande passante mémoire du GPU que les schémas d'accès plus aléatoires des opérations MSM des SNARK.
impl<F: PrimeField> StarkProver<F> {
fn prove(&self, trace: &ExecutionTrace<F>) -> StarkProof<F> {
// polynomial interpolation via ntt
let trace_poly = self.interpolate(trace);
let composition_poly = self.evaluate_constraints(&trace_poly);
let fri_proof = self.fri_commit(&composition_poly);
StarkProof {
trace_commitment: self.commit(&trace_poly),
composition_commitment: self.commit(&composition_poly),
fri_proof,
}
}
}Les zkEVM posent des défis particuliers en raison de la complexité des instructions EVM et de la taille des représentations d'état. Le processus de génération de preuve consiste à générer des contraintes pour chaque opcode EVM et chaque transition d'état.
impl ZkEvmProver {
fn prove_block(&self, block: &Block) -> ZkEvmProof {
let mut circuit = EvmCircuit::new();
for tx in &block.transactions {
for (pc, opcode) in tx.trace.iter().enumerate() {
match opcode {
OpCode::ADD => {
let (a, b) = self.stack.pop2();
let result = a + b;
circuit.add_constraint(a + b - result);
self.stack.push(result);
},
OpCode::SSTORE => {
let (key, value) = self.stack.pop2();
circuit.add_storage_constraint(key, value);
self.update_state_root(key, value);
},
// ...
}
}
}
self.prove_circuit(circuit)
}
}Chaque opcode, de ADD à SSTORE, doit être traduit en un ensemble de contraintes polynomiales.
Les zkEVM doivent en général toujours prouver la validité des schémas d'accès mémoire et des mises à jour du stockage. Cela implique généralement des opérations sur un Merkle tree pour la vérification de la racine d'état
fn constrain_merkle_update(
circuit: &mut Circuit,
old_root: Variable,
new_root: Variable,
key: Variable,
old_value: Variable,
new_value: Variable,
path: &[Variable],
) {
let mut current = old_value;
let mut updated = new_value;
for (i, &sibling) in path.iter().enumerate() {
let bit = circuit.extract_bit(key, i);
let hash_input = circuit.select(bit, (sibling, current), (current, sibling));
current = circuit.poseidon_hash(hash_input.0, hash_input.1);
let updated_input = circuit.select(bit, (sibling, updated), (updated, sibling));
updated = circuit.poseidon_hash(updated_input.0, updated_input.1);
}
circuit.assert_equal(current, old_root);
circuit.assert_equal(updated, new_root);
}L'accélération GPU devient généralement importante pour les nombreuses opérations de hachage nécessaires à la vérification des transitions d'état
Les GPU n'ont pas seulement accéléré la génération de preuves ZK, ils ont en gros changé la manière de les générer en résolvant le problème du « prover gourmand en ressources ». Nous avons maintenant des Layer 2 qui atteignent la finalité en quelques secondes et de la DeFi privée qui tourne en production, ce qui n'était pas vraiment possible auparavant. À mesure que la complexité des circuits augmente avec des applications comme les zkEVMs, l'accélération GPU n'est plus seulement une question d'optimisation, c'est une exigence fondamentale pour les déploiements en production.
Recevez les derniers articles dans votre boîte mail.