Tous les articles

Génération de preuves zero-knowledge accélérée par GPU

·
7 sept. 2025
·
5 min de lecture
·
••• vues
·
••• j'aime

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

Génération de preuves zero-knowledge accélérée par GPU

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.

Qu'est-ce qui prend vraiment autant de temps ?

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

Qu'est-ce que la Multi-Scalar Multiplication ?

La MSM calcule la somme de points de courbe elliptique multipliés par des scalaires : S=∑i=1nai⋅PiS = \sum_{i=1}^{n} a_i \cdot P_i. 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.

Qu'est-ce que la Number Theoretic Transform ?

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.

Étude de cas : performances de génération de preuves sur CPU vs GPU

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.

Scénario : génération de preuve d'un bloc zkEVM

Les deux séquenceurs doivent prouver un bloc contenant 500 transactions avec les opérations suivantes :

Cela génère un circuit d'environ 2262^{26} contraintes, ce qui est typique d'un bloc zkEVM de taille moyenne.

L'approche CPU d'Alice

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 :

L'approche accélérée par GPU de Bob

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 :

Comparaison des résultats

OpérationAlice (CPU)Bob (GPU)Accélération
Compilation du circuit45s12s3,8×
Opérations MSM180s8s22,5×
Opérations NTT90s3s30×
Preuve complète du bloc315s23s13,7×

Pourquoi ça change tout

Finalité du réseau :

Coût par transaction

// 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) :

Ce dont Alice a besoin pour rivaliser

Alice aurait besoin de 14 CPU pour égaler la configuration à un seul GPU de Bob, ce qui entraîne :

Comment la mémoire est lue

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.

Schémas d'accélération SNARK vs. STARK

Systèmes SNARK

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

Systèmes STARK

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

Accélération des zkEVM

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.

Prouver l'EVM

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.

Prouver l'arbre d'état

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

Conclusion

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.

Liens essentiels

•••
Partager cet article :

Restez informé

Recevez les derniers articles dans votre boîte mail.